initial commit
This commit is contained in:
commit
910b438686
19 files changed
+1790
No files matched your search
@@ -0,0 +1,3 @@
|
||||
# Shell scripts break with Windows line endings; keep everything LF.
|
||||
* text=auto eol=lf
|
||||
*.deb binary
|
||||
@@ -0,0 +1,2 @@
|
||||
build/
|
||||
*.deb
|
||||
@@ -0,0 +1,34 @@
|
||||
# Changelog
|
||||
|
||||
## 2.0.0 - 2026-09-28
|
||||
|
||||
- Renamed the command from `terraria-instance` to `terractl`, and the package from `terraria-instance` to `terraria-server-manager`. The new package replaces the old one automatically; see "Upgrading from the terraria-instance package" in the guide.
|
||||
- Settings file moved from `/etc/default/terraria-instance` to `/etc/default/terractl`.
|
||||
- New `terractl --version`.
|
||||
- New Makefile: `make check`, `make deb`, `make install`, `make uninstall`.
|
||||
- Restructured the source into a git repository layout.
|
||||
|
||||
## 1.0.4 - 2026-09-28
|
||||
|
||||
- Fixed `attach`: detaching with Ctrl-D printed "jpid: unbound variable" and left a `journalctl` process running.
|
||||
|
||||
## 1.0.3 - 2026-09-28
|
||||
|
||||
- The .NET runtime that tModLoader downloads on first start (and its logs) is now kept when an instance is upgraded or re-unpacked.
|
||||
- New `reinstall --clean` to discard .NET as well and download it again.
|
||||
|
||||
## 1.0.2 - 2026-09-28
|
||||
|
||||
- New `mods` command: `list`, `add`, `enable`, `disable`, `remove`. Accepts the Windows export folder directly and sets file ownership.
|
||||
- `new` and `prepare` now create `data/Mods` for tModLoader instances.
|
||||
|
||||
## 1.0.1 - 2026-09-28
|
||||
|
||||
- Fixed `prepare` failing for tModLoader zips when run as root from a directory the `terraria` user can't enter (e.g. `/root`).
|
||||
|
||||
## 1.0.0 - 2026-09-28
|
||||
|
||||
- First Debian package. Uses `/var/lib/terraria` instead of `/opt/terraria` and `/terraria`, creates the `terraria` user and directories with sysusers.d and tmpfiles.d, and stops instances cleanly when removed.
|
||||
- `prepare` and `run` switch to the `terraria` user when started as root, so no files end up owned by root.
|
||||
- New `delete` command, with an automatic backup.
|
||||
- `list` no longer aborts when an instance's config can't be read.
|
||||
@@ -0,0 +1,88 @@
|
||||
# terraria-server-manager
|
||||
#
|
||||
# make check syntax-check the scripts (and shellcheck, if installed)
|
||||
# make deb build build/terraria-server-manager_<version>_all.deb
|
||||
# sudo make install install directly (default PREFIX=/usr/local), no package
|
||||
# sudo make uninstall remove what 'make install' installed (keeps your data)
|
||||
# make clean remove build/
|
||||
|
||||
NAME := terraria-server-manager
|
||||
VERSION := $(shell cat VERSION)
|
||||
|
||||
PREFIX ?= /usr/local
|
||||
SYSCONFDIR ?= /etc
|
||||
BINDIR := $(PREFIX)/bin
|
||||
UNITDIR := $(PREFIX)/lib/systemd/system
|
||||
SYSUSERSDIR := $(PREFIX)/lib/sysusers.d
|
||||
TMPFILESDIR := $(PREFIX)/lib/tmpfiles.d
|
||||
DOCDIR := $(PREFIX)/share/doc/$(NAME)
|
||||
DATADIR := $(PREFIX)/share/$(NAME)
|
||||
|
||||
# Fill in version and install paths
|
||||
SUBST = sed -e 's|@VERSION@|$(VERSION)|g' -e 's|@BINDIR@|$(BINDIR)|g' -e 's|@DOCDIR@|$(DOCDIR)|g'
|
||||
|
||||
MAINT_SCRIPTS := packaging/deb/postinst packaging/deb/prerm packaging/deb/postrm
|
||||
STAGE := build/deb-root
|
||||
DEB := build/$(NAME)_$(VERSION)_all.deb
|
||||
|
||||
.PHONY: all check install uninstall deb clean
|
||||
|
||||
all: check
|
||||
|
||||
check:
|
||||
bash -n bin/terractl
|
||||
for f in $(MAINT_SCRIPTS); do sh -n $$f; done
|
||||
@if command -v shellcheck >/dev/null; then \
|
||||
shellcheck bin/terractl $(MAINT_SCRIPTS); \
|
||||
else \
|
||||
echo "shellcheck not installed; skipped (apt install shellcheck)"; \
|
||||
fi
|
||||
|
||||
install:
|
||||
install -d $(DESTDIR)$(BINDIR) $(DESTDIR)$(UNITDIR) $(DESTDIR)$(SYSUSERSDIR) \
|
||||
$(DESTDIR)$(TMPFILESDIR) $(DESTDIR)$(DOCDIR) $(DESTDIR)$(DATADIR) \
|
||||
$(DESTDIR)$(SYSCONFDIR)/default
|
||||
$(SUBST) bin/terractl > $(DESTDIR)$(BINDIR)/terractl
|
||||
chmod 755 $(DESTDIR)$(BINDIR)/terractl
|
||||
$(SUBST) systemd/terraria@.service > $(DESTDIR)$(UNITDIR)/terraria@.service
|
||||
install -m 644 systemd/terraria@.socket $(DESTDIR)$(UNITDIR)/
|
||||
install -m 644 config/sysusers.d/terractl.conf $(DESTDIR)$(SYSUSERSDIR)/
|
||||
install -m 644 config/tmpfiles.d/terractl.conf $(DESTDIR)$(TMPFILESDIR)/
|
||||
install -m 644 docs/guide.md $(DESTDIR)$(DOCDIR)/README.md
|
||||
install -m 644 tools/windows/export-tmod-mods.ps1 $(DESTDIR)$(DATADIR)/
|
||||
@# Never overwrite local settings
|
||||
@if [ -e $(DESTDIR)$(SYSCONFDIR)/default/terractl ]; then \
|
||||
echo "keeping existing $(DESTDIR)$(SYSCONFDIR)/default/terractl"; \
|
||||
else \
|
||||
install -m 644 config/default/terractl $(DESTDIR)$(SYSCONFDIR)/default/terractl; \
|
||||
fi
|
||||
@# On a real install (not staging for a package), set up the user and directories
|
||||
@if [ -z "$(DESTDIR)" ]; then \
|
||||
systemd-sysusers terractl.conf && \
|
||||
systemd-tmpfiles --create terractl.conf && \
|
||||
if [ -d /run/systemd/system ]; then systemctl daemon-reload; fi && \
|
||||
echo "Installed terractl $(VERSION). See $(DOCDIR)/README.md"; \
|
||||
fi
|
||||
|
||||
uninstall:
|
||||
rm -f $(DESTDIR)$(BINDIR)/terractl \
|
||||
$(DESTDIR)$(UNITDIR)/terraria@.service $(DESTDIR)$(UNITDIR)/terraria@.socket \
|
||||
$(DESTDIR)$(SYSUSERSDIR)/terractl.conf $(DESTDIR)$(TMPFILESDIR)/terractl.conf
|
||||
rm -rf $(DESTDIR)$(DOCDIR) $(DESTDIR)$(DATADIR)
|
||||
@if [ -z "$(DESTDIR)" ] && [ -d /run/systemd/system ]; then systemctl daemon-reload; fi
|
||||
@echo "Removed. Kept: $(SYSCONFDIR)/default/terractl, /var/lib/terraria and the terraria user."
|
||||
|
||||
deb: check
|
||||
rm -rf $(STAGE)
|
||||
$(MAKE) --no-print-directory install DESTDIR=$(CURDIR)/$(STAGE) PREFIX=/usr
|
||||
install -d -m 755 $(STAGE)/DEBIAN
|
||||
install -m 644 packaging/deb/conffiles $(STAGE)/DEBIAN/
|
||||
install -m 755 $(MAINT_SCRIPTS) $(STAGE)/DEBIAN/
|
||||
sed -e 's|@VERSION@|$(VERSION)|' \
|
||||
-e "s|@INSTALLED_SIZE@|$$(du -sk --exclude=DEBIAN $(STAGE) | cut -f1)|" \
|
||||
packaging/deb/control > $(STAGE)/DEBIAN/control
|
||||
find $(STAGE) -type d -exec chmod 755 {} +
|
||||
dpkg-deb --build --root-owner-group $(STAGE) $(DEB)
|
||||
|
||||
clean:
|
||||
rm -rf build
|
||||
@@ -0,0 +1,70 @@
|
||||
# terraria-server-manager
|
||||
|
||||
Run any number of vanilla **Terraria** and **tModLoader** dedicated servers on one Linux host. Each server is its own systemd service, and they're all managed with the `terractl` command.
|
||||
|
||||
```bash
|
||||
sudo terractl new calamity tmodloader
|
||||
sudo terractl mods calamity add ~/tmod-export
|
||||
sudo systemctl enable --now terraria@calamity
|
||||
sudo terractl attach calamity
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Many servers, one host.** Each instance is `terraria@<name>.service`, with its own directory, port, world and mods.
|
||||
- **Vanilla and tModLoader side by side**, chosen per instance.
|
||||
- **No manual installs.** Drop the official server zips into `/var/lib/terraria/dist`; they're unpacked on demand, and each instance can be pinned to a specific version.
|
||||
- **Safe shutdowns.** Stopping a server saves the world first.
|
||||
- **A live console.** `terractl attach` shows the server's output and lets you type commands into it.
|
||||
- **Mod management.** `terractl mods` installs, enables and removes mods, with correct file ownership.
|
||||
- **Hardened.** Servers run as an unprivileged `terraria` user and can only write to their own data.
|
||||
- **Helper for Windows.** A PowerShell script exports your enabled tModLoader mods for the server.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Debian 12+ or Ubuntu 22.04+ (other systemd-based distributions should work with `make install`)
|
||||
- `unzip`, `systemd`; for tModLoader, also `curl` and ICU (`libicu`)
|
||||
|
||||
## Install
|
||||
|
||||
Download the `.deb` from the releases page, then:
|
||||
|
||||
```bash
|
||||
sudo apt install ./terraria-server-manager_<version>_all.deb
|
||||
```
|
||||
|
||||
Or install from source, without a package:
|
||||
|
||||
```bash
|
||||
sudo make install
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
See [docs/guide.md](docs/guide.md). It covers installation, creating instances, the console, configuration, mods, versions and upgrades, permissions and troubleshooting. Installed systems also have it at `/usr/share/doc/terraria-server-manager/README.md`.
|
||||
|
||||
## Repository layout
|
||||
|
||||
```
|
||||
bin/terractl The command-line tool
|
||||
systemd/ terraria@.service and terraria@.socket
|
||||
config/ /etc/default settings, sysusers.d and tmpfiles.d entries
|
||||
packaging/deb/ Debian package metadata and install/remove scripts
|
||||
docs/guide.md Full documentation
|
||||
tools/windows/ export-tmod-mods.ps1 for exporting mods from Windows
|
||||
VERSION Current version, used by the build
|
||||
Makefile make check | deb | install | uninstall | clean
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
make check # bash -n on all scripts, plus shellcheck if installed
|
||||
make deb # build/terraria-server-manager_<version>_all.deb
|
||||
```
|
||||
|
||||
To release: update `VERSION` and `CHANGELOG.md`, run `make deb`, and attach the `.deb` to the release.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
This is an unofficial community tool. It is not affiliated with or endorsed by Re-Logic or the tModLoader team. It does not include any Terraria or tModLoader software; you supply the official server downloads.
|
||||
Executable
+601
@@ -0,0 +1,601 @@
|
||||
#!/usr/bin/env bash
|
||||
# terractl - helper for terraria@.service
|
||||
#
|
||||
# Layout:
|
||||
# /var/lib/terraria/dist/ server zips (read-only source)
|
||||
# terraria-server-*.zip vanilla dedicated server zips from terraria.org
|
||||
# tModLoader*.zip tModLoader release zips (GitHub)
|
||||
# /var/lib/terraria/instances/<instance>/
|
||||
# instance.conf TYPE (vanilla|tmodloader), ZIP pin, EXTRA_ARGS
|
||||
# serverconfig.txt normal Terraria server config (port, world, ...)
|
||||
# server/ unpacked server binaries (replaceable)
|
||||
# data/ worlds, mods, player data (kept across reinstalls)
|
||||
set -euo pipefail
|
||||
|
||||
# All paths used are absolute. Start from / so nothing depends on the caller's
|
||||
# directory (which the 'terraria' user may not be allowed into, e.g. /root).
|
||||
cd /
|
||||
|
||||
# Local overrides (see the comments in that file)
|
||||
# shellcheck disable=SC1091
|
||||
if [[ -r /etc/default/terractl ]]; then . /etc/default/terractl; fi
|
||||
|
||||
BASE_DIR="${TERRARIA_BASE:-/var/lib/terraria/instances}"
|
||||
DIST_DIR="${TERRARIA_DIST:-/var/lib/terraria/dist}"
|
||||
RUN_DIR="/run/terraria"
|
||||
SVC_USER="${TERRARIA_USER:-terraria}"
|
||||
STOP_TIMEOUT="${TERRARIA_STOP_TIMEOUT:-80}" # keep below TimeoutStopSec
|
||||
TERRACTL_VERSION="@VERSION@" # filled in by 'make install' / 'make deb'
|
||||
|
||||
die() { echo "terractl: $*" >&2; exit 1; }
|
||||
log() { echo "terractl: $*" >&2; }
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: terractl <command> <instance> [args]
|
||||
terractl --version
|
||||
|
||||
Admin commands:
|
||||
new <name> [vanilla|tmodloader] [port] create an instance (run as root)
|
||||
list show instances, type, port, state
|
||||
attach <name> interactive console (Ctrl-D to detach)
|
||||
cmd <name> <console command...> send a console command (e.g. save, say hi)
|
||||
reinstall <name> [--clean] re-unpack server on next start
|
||||
(--clean also re-downloads tModLoader's .NET)
|
||||
mods <name> [list] show installed mods and whether they're enabled
|
||||
mods <name> add <file.tmod|folder>... install mods (and ModConfigs) and enable them
|
||||
mods <name> enable|disable <mod>... change which installed mods load
|
||||
mods <name> remove <mod>... uninstall mods
|
||||
delete <name> [-y] [--no-backup] stop, disable and remove an instance
|
||||
(worlds/configs archived to $BASE_DIR/.deleted/)
|
||||
|
||||
Used by terraria@.service:
|
||||
prepare <name> create dirs/configs, unpack server if needed
|
||||
run <name> exec the server
|
||||
stop <name> <mainpid> graceful shutdown via console "exit"
|
||||
EOF
|
||||
exit "${1:-1}"
|
||||
}
|
||||
|
||||
valid_name() {
|
||||
# Must not start with '.', so '.', '..' and hidden dirs can never be targeted
|
||||
[[ ${1:-} =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]*$ ]] || die "invalid instance name '${1:-}'"
|
||||
}
|
||||
|
||||
inst_dir() { echo "$BASE_DIR/$1"; }
|
||||
|
||||
load_conf() {
|
||||
local dir; dir=$(inst_dir "$1")
|
||||
TYPE=vanilla
|
||||
ZIP=""
|
||||
EXTRA_ARGS=()
|
||||
# shellcheck disable=SC1091
|
||||
[[ -f $dir/instance.conf ]] && source "$dir/instance.conf"
|
||||
case $TYPE in
|
||||
vanilla|tmodloader) ;;
|
||||
*) die "TYPE in $dir/instance.conf must be 'vanilla' or 'tmodloader' (got '$TYPE')" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
write_instance_conf() {
|
||||
local name=$1 type=$2 dir; dir=$(inst_dir "$name")
|
||||
cat > "$dir/instance.conf" <<EOF
|
||||
# Settings for terraria@$name
|
||||
# Changing TYPE or ZIP makes the next start re-unpack server/ (data/ is kept).
|
||||
|
||||
TYPE=$type # vanilla | tmodloader
|
||||
|
||||
# Pin a specific zip from $DIST_DIR (filename or absolute path).
|
||||
# Blank = newest matching zip at install time; it is NOT auto-upgraded later.
|
||||
ZIP=
|
||||
|
||||
# Extra arguments passed to the server binary, as a bash array, e.g.
|
||||
# EXTRA_ARGS=(-disableannouncementbox)
|
||||
EXTRA_ARGS=()
|
||||
EOF
|
||||
}
|
||||
|
||||
next_free_port() {
|
||||
local max=7776 p f
|
||||
for f in "$BASE_DIR"/*/serverconfig.txt; do
|
||||
[[ -f $f ]] || continue
|
||||
p=$(sed -n 's/^port=\([0-9][0-9]*\).*/\1/p' "$f" | tail -n1)
|
||||
if [[ -n $p && $p -gt $max ]]; then max=$p; fi
|
||||
done
|
||||
echo $((max + 1))
|
||||
}
|
||||
|
||||
write_server_config() {
|
||||
local name=$1 port=${2:-} dir; dir=$(inst_dir "$name")
|
||||
[[ -n $port ]] || port=$(next_free_port)
|
||||
cat > "$dir/serverconfig.txt" <<EOF
|
||||
# Terraria server config for instance '$name'
|
||||
# Edit freely, then: systemctl restart terraria@$name
|
||||
|
||||
world=$dir/data/Worlds/$name.wld
|
||||
worldpath=$dir/data/Worlds
|
||||
# Created automatically if the world file does not exist. 1=small 2=medium 3=large
|
||||
autocreate=2
|
||||
worldname=$name
|
||||
# 0=classic 1=expert 2=master 3=journey
|
||||
difficulty=0
|
||||
#seed=
|
||||
|
||||
port=$port
|
||||
maxplayers=8
|
||||
password=
|
||||
motd=Welcome to $name
|
||||
secure=1
|
||||
upnp=0
|
||||
language=en-US
|
||||
banlist=$dir/data/banlist.txt
|
||||
EOF
|
||||
log "created $dir/serverconfig.txt (port $port)"
|
||||
}
|
||||
|
||||
pick_zip() {
|
||||
local pattern f
|
||||
if [[ -n $ZIP ]]; then
|
||||
if [[ $ZIP == /* ]]; then echo "$ZIP"; else echo "$DIST_DIR/$ZIP"; fi
|
||||
return
|
||||
fi
|
||||
case $TYPE in
|
||||
vanilla) pattern='terraria-server-*.zip' ;;
|
||||
tmodloader) pattern='tModLoader*.zip' ;;
|
||||
esac
|
||||
f=$(find "$DIST_DIR" -maxdepth 1 -type f -name "$pattern" -printf '%f\n' | sort -V | tail -n1)
|
||||
[[ -n $f ]] || die "no '$pattern' found in $DIST_DIR"
|
||||
echo "$DIST_DIR/$f"
|
||||
}
|
||||
|
||||
# Global (not local) so the EXIT trap can still see them after functions return
|
||||
EXTRACT_TMP=""
|
||||
ATTACH_PID=""
|
||||
cleanup() {
|
||||
if [[ -n $EXTRACT_TMP && -d $EXTRACT_TMP ]]; then rm -rf "$EXTRACT_TMP"; fi
|
||||
if [[ -n $ATTACH_PID ]]; then kill "$ATTACH_PID" 2>/dev/null || true; fi
|
||||
return 0
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
install_server() {
|
||||
local name=$1 dir marker zip src have_type="" have_zip=""
|
||||
dir=$(inst_dir "$name")
|
||||
marker="$dir/server/.installed"
|
||||
|
||||
# Already installed with the right type (and pinned zip, if any)? Nothing to do.
|
||||
if [[ -f $marker ]]; then
|
||||
read -r have_type have_zip < "$marker" || true
|
||||
if [[ $have_type == "$TYPE" ]] && [[ -z $ZIP || $have_zip == "$(basename "$ZIP")" ]]; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
command -v unzip >/dev/null || die "unzip is not installed"
|
||||
zip=$(pick_zip)
|
||||
[[ -r $zip ]] || die "cannot read $zip"
|
||||
log "installing $TYPE from $(basename "$zip") into $dir/server"
|
||||
|
||||
EXTRACT_TMP=$(mktemp -d "$dir/.extract.XXXXXX")
|
||||
unzip -o -q "$zip" -d "$EXTRACT_TMP"
|
||||
|
||||
case $TYPE in
|
||||
vanilla)
|
||||
# terraria.org zips contain <version>/{Linux,Windows,Mac}; we only want Linux
|
||||
src=$(find "$EXTRACT_TMP" -type f -name TerrariaServer.bin.x86_64 -path '*/Linux/*' -printf '%h\n' -quit)
|
||||
[[ -n $src ]] || die "Linux/TerrariaServer.bin.x86_64 not found in $zip"
|
||||
chmod +x "$src/TerrariaServer.bin.x86_64"
|
||||
[[ -f $src/TerrariaServer ]] && chmod +x "$src/TerrariaServer"
|
||||
;;
|
||||
tmodloader)
|
||||
src=$(find "$EXTRACT_TMP" -type f -name start-tModLoaderServer.sh -printf '%h\n' -quit)
|
||||
[[ -n $src ]] || die "start-tModLoaderServer.sh not found in $zip"
|
||||
find "$src" -type f -name '*.sh' -exec chmod +x {} +
|
||||
;;
|
||||
esac
|
||||
|
||||
# tModLoader releases don't include .NET; it downloads it into server/dotnet on
|
||||
# first start. Carry that (and the logs) over so an upgrade or reinstall doesn't
|
||||
# download it again. tModLoader checks the .NET version itself on every start
|
||||
# and installs a different one if the new release needs it.
|
||||
if [[ $TYPE == tmodloader && -d $dir/server ]]; then
|
||||
local keep
|
||||
for keep in dotnet tModLoader-Logs; do
|
||||
if [[ -d $dir/server/$keep && ! -e $src/$keep ]]; then
|
||||
mv "$dir/server/$keep" "$src/$keep"
|
||||
log "kept existing server/$keep"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
rm -rf "$dir/server"
|
||||
mv "$src" "$dir/server"
|
||||
echo "$TYPE $(basename "$zip")" > "$dir/server/.installed"
|
||||
cleanup
|
||||
log "install complete"
|
||||
}
|
||||
|
||||
cmd_prepare() {
|
||||
local name=$1 dir; dir=$(inst_dir "$name")
|
||||
mkdir -p "$dir/data/Worlds"
|
||||
[[ -f $dir/instance.conf ]] || write_instance_conf "$name" vanilla
|
||||
load_conf "$name"
|
||||
if [[ $TYPE == tmodloader ]]; then mkdir -p "$dir/data/Mods"; fi
|
||||
[[ -f $dir/serverconfig.txt ]] || write_server_config "$name"
|
||||
install_server "$name"
|
||||
}
|
||||
|
||||
cmd_run() {
|
||||
local name=$1 dir; dir=$(inst_dir "$name")
|
||||
load_conf "$name"
|
||||
[[ -f $dir/server/.installed ]] || die "server not installed; run 'prepare' first"
|
||||
# Per-instance HOME so nothing is shared through ~/.local or ~/.dotnet
|
||||
export HOME="$dir"
|
||||
cd "$dir/server"
|
||||
case $TYPE in
|
||||
vanilla)
|
||||
exec ./TerrariaServer.bin.x86_64 -config "$dir/serverconfig.txt" "${EXTRA_ARGS[@]}"
|
||||
;;
|
||||
tmodloader)
|
||||
# -nosteam skips the interactive "use steam server?" prompt.
|
||||
# -tmlsavedirectory keeps worlds, Mods/ and configs under data/.
|
||||
# First start downloads a matching .NET runtime into server/dotnet.
|
||||
exec ./start-tModLoaderServer.sh -nosteam \
|
||||
-config "$dir/serverconfig.txt" \
|
||||
-tmlsavedirectory "$dir/data" \
|
||||
"${EXTRA_ARGS[@]}"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
cmd_stop() {
|
||||
local name=$1 pid=${2:-} fifo="$RUN_DIR/$1.stdin" i
|
||||
if [[ -z $pid ]] || ! kill -0 "$pid" 2>/dev/null; then exit 0; fi
|
||||
if [[ -p $fifo ]]; then
|
||||
echo exit > "$fifo"
|
||||
else
|
||||
log "console FIFO missing; systemd will send SIGTERM"
|
||||
exit 0
|
||||
fi
|
||||
for ((i = 0; i < STOP_TIMEOUT; i++)); do
|
||||
kill -0 "$pid" 2>/dev/null || exit 0
|
||||
sleep 1
|
||||
done
|
||||
log "server did not exit within ${STOP_TIMEOUT}s; leaving it to systemd"
|
||||
}
|
||||
|
||||
cmd_cmd() {
|
||||
local name=$1; shift
|
||||
[[ $# -gt 0 ]] || die "no console command given"
|
||||
local fifo="$RUN_DIR/$name.stdin"
|
||||
[[ -p $fifo ]] || die "$fifo not found - is terraria@$name running?"
|
||||
printf '%s\n' "$*" > "$fifo"
|
||||
echo "sent. Output: journalctl -u terraria@$name -n 20"
|
||||
}
|
||||
|
||||
cmd_attach() {
|
||||
local name=$1 fifo="$RUN_DIR/$1.stdin" line
|
||||
[[ -p $fifo ]] || die "$fifo not found - is terraria@$name running?"
|
||||
[[ -w $fifo ]] || die "no write access to $fifo (run with sudo or join group '$SVC_USER')"
|
||||
|
||||
echo "Attached to terraria@$name. Ctrl-D or Ctrl-C detaches; the server keeps running."
|
||||
# Server output comes from the journal; -o cat drops the timestamp prefixes.
|
||||
journalctl -u "terraria@$name" -f -n 30 -o cat &
|
||||
ATTACH_PID=$! # killed by cleanup() however attach ends
|
||||
trap 'echo; exit 0' INT
|
||||
|
||||
# -e gives line editing and history while typing commands
|
||||
while IFS= read -r -e line; do
|
||||
[[ -z $line ]] && continue
|
||||
if [[ $line == exit ]]; then
|
||||
echo "(not sent: 'exit' would shut the server down. Use Ctrl-D to detach," \
|
||||
"or 'systemctl stop terraria@$name' to stop it.)"
|
||||
continue
|
||||
fi
|
||||
printf '%s\n' "$line" > "$fifo"
|
||||
done
|
||||
echo
|
||||
}
|
||||
|
||||
cmd_delete() {
|
||||
local name=$1; shift
|
||||
local assume_yes=0 backup=1 dir archive answer
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-y|--yes) assume_yes=1 ;;
|
||||
--no-backup) backup=0 ;;
|
||||
*) die "unknown option '$1'" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
[[ $EUID -eq 0 ]] || die "delete must be run as root"
|
||||
dir=$(inst_dir "$name")
|
||||
[[ -d $dir ]] || die "$dir does not exist"
|
||||
# Refuse to remove anything that doesn't look like one of our instances
|
||||
[[ -f $dir/instance.conf ]] || die "$dir has no instance.conf; not deleting it"
|
||||
|
||||
if [[ $assume_yes -ne 1 ]]; then
|
||||
[[ -t 0 ]] || die "not a terminal; use -y to delete without confirmation"
|
||||
echo "This will stop terraria@$name and remove $dir."
|
||||
if [[ $backup -eq 1 ]]; then
|
||||
echo "Worlds and configs will be archived to $BASE_DIR/.deleted/ first."
|
||||
else
|
||||
echo "NO BACKUP will be made. Worlds will be lost."
|
||||
fi
|
||||
read -r -p "Type the instance name to confirm: " answer
|
||||
[[ $answer == "$name" ]] || die "confirmation did not match; nothing deleted"
|
||||
fi
|
||||
|
||||
if command -v systemctl >/dev/null; then
|
||||
systemctl disable --now "terraria@$name.service" 2>/dev/null || true
|
||||
systemctl stop "terraria@$name.socket" 2>/dev/null || true
|
||||
systemctl reset-failed "terraria@$name.service" "terraria@$name.socket" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
if [[ $backup -eq 1 ]]; then
|
||||
mkdir -p "$BASE_DIR/.deleted"
|
||||
chmod 700 "$BASE_DIR/.deleted"
|
||||
archive="$BASE_DIR/.deleted/$name-$(date +%Y%m%d-%H%M%S).tar.gz"
|
||||
# server/ is excluded: it's just the unpacked zip and can be recreated
|
||||
tar -czf "$archive" -C "$BASE_DIR" \
|
||||
--exclude="$name/server" --exclude="$name/.extract.*" "$name"
|
||||
echo "backup: $archive"
|
||||
fi
|
||||
|
||||
rm -rf -- "$dir"
|
||||
echo "deleted instance '$name'"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- mods ----
|
||||
# enabled.json is a plain JSON array of mod names, e.g. ["CalamityMod","MagicStorage"].
|
||||
# Mod names are identifiers, so simple text handling is enough (no jq needed).
|
||||
|
||||
fix_owner() { if [[ $EUID -eq 0 ]]; then chown "$SVC_USER:$SVC_USER" "$@"; fi; }
|
||||
|
||||
read_enabled() {
|
||||
local f="$1/enabled.json"
|
||||
[[ -f $f ]] || return 0
|
||||
grep -o '"[^"]*"' "$f" | tr -d '"' || true
|
||||
}
|
||||
|
||||
write_enabled() { # write_enabled <mods dir> <name>...
|
||||
local mdir=$1; shift
|
||||
local out="[" sep="" n
|
||||
for n in "$@"; do out+="$sep"$'\n'" \"$n\""; sep=","; done
|
||||
if [[ $# -gt 0 ]]; then out+=$'\n'; fi
|
||||
out+="]"
|
||||
printf '%s\n' "$out" > "$mdir/.enabled.json.tmp"
|
||||
mv "$mdir/.enabled.json.tmp" "$mdir/enabled.json"
|
||||
fix_owner "$mdir/enabled.json"
|
||||
}
|
||||
|
||||
mods_restart_hint() {
|
||||
if systemctl is-active --quiet "terraria@$name" 2>/dev/null; then
|
||||
echo "Restart to apply: sudo systemctl restart terraria@$name"
|
||||
fi
|
||||
}
|
||||
|
||||
mods_list() {
|
||||
local enabled f n st
|
||||
enabled=$(read_enabled "$mdir")
|
||||
local -a files=()
|
||||
for f in "$mdir"/*.tmod; do
|
||||
if [[ -f $f ]]; then files+=("$f"); fi
|
||||
done
|
||||
if [[ ${#files[@]} -eq 0 ]]; then
|
||||
echo "No mods installed in $mdir"
|
||||
echo "Add some with: terractl mods $name add <folder or .tmod files>"
|
||||
return 0
|
||||
fi
|
||||
printf '%-32s %s\n' MOD STATE
|
||||
for f in "${files[@]}"; do
|
||||
n=$(basename "$f" .tmod)
|
||||
if grep -qxF "$n" <<<"$enabled"; then st=enabled; else st=disabled; fi
|
||||
printf '%-32s %s\n' "$n" "$st"
|
||||
done
|
||||
for n in $enabled; do
|
||||
if [[ ! -f $mdir/$n.tmod ]]; then printf '%-32s %s\n' "$n" "enabled but MISSING (server will fail to load mods)"; fi
|
||||
done
|
||||
}
|
||||
|
||||
mods_add() {
|
||||
[[ $# -gt 0 ]] || die "usage: terractl mods $name add <file.tmod|folder>..."
|
||||
local src f n found
|
||||
local -a files=() cfgs=()
|
||||
for src in "$@"; do
|
||||
if [[ -f $src ]]; then
|
||||
[[ $src == *.tmod ]] || die "$src is not a .tmod file"
|
||||
files+=("$src")
|
||||
elif [[ -d $src ]]; then
|
||||
# Accepts a folder of .tmod files, or an export folder containing Mods/ and ModConfigs/
|
||||
found=0
|
||||
for f in "$src"/*.tmod "$src"/Mods/*.tmod; do
|
||||
if [[ -f $f ]]; then files+=("$f"); found=1; fi
|
||||
done
|
||||
[[ $found -eq 1 ]] || die "no .tmod files in $src or $src/Mods"
|
||||
for f in "$src"/ModConfigs/*.json; do
|
||||
if [[ -f $f ]]; then cfgs+=("$f"); fi
|
||||
done
|
||||
else
|
||||
die "$src not found"
|
||||
fi
|
||||
done
|
||||
|
||||
local -A pick=()
|
||||
for f in "${files[@]}"; do
|
||||
n=$(basename "$f" .tmod)
|
||||
[[ $n =~ ^[A-Za-z0-9_]+$ ]] || die "unexpected mod file name '$n.tmod'"
|
||||
if [[ -n ${pick[$n]:-} ]]; then log "warning: $n given more than once; using $f"; fi
|
||||
pick[$n]=$f
|
||||
done
|
||||
|
||||
mkdir -p "$mdir"
|
||||
fix_owner "$dir/data" "$mdir"
|
||||
for n in "${!pick[@]}"; do
|
||||
install -m 0640 "${pick[$n]}" "$mdir/$n.tmod"
|
||||
fix_owner "$mdir/$n.tmod"
|
||||
echo "installed $n"
|
||||
done
|
||||
|
||||
if [[ ${#cfgs[@]} -gt 0 ]]; then
|
||||
mkdir -p "$dir/data/ModConfigs"
|
||||
fix_owner "$dir/data/ModConfigs"
|
||||
for f in "${cfgs[@]}"; do
|
||||
install -m 0640 "$f" "$dir/data/ModConfigs/$(basename "$f")"
|
||||
fix_owner "$dir/data/ModConfigs/$(basename "$f")"
|
||||
done
|
||||
echo "installed ${#cfgs[@]} mod config file(s)"
|
||||
fi
|
||||
|
||||
local -a current=()
|
||||
mapfile -t current < <(read_enabled "$mdir")
|
||||
# shellcheck disable=SC2046
|
||||
write_enabled "$mdir" $(printf '%s\n' "${current[@]}" "${!pick[@]}" | sort -u)
|
||||
echo "enabled: ${!pick[*]}"
|
||||
echo "Note: dependencies are not checked here; if one is missing, the server log will say which."
|
||||
mods_restart_hint
|
||||
}
|
||||
|
||||
mods_set() { # mods_set 1|0 <name>...
|
||||
local on=$1; shift
|
||||
[[ $# -gt 0 ]] || die "no mod names given"
|
||||
local n list
|
||||
local -a current=()
|
||||
mapfile -t current < <(read_enabled "$mdir")
|
||||
if [[ $on -eq 1 ]]; then
|
||||
for n in "$@"; do
|
||||
[[ -f $mdir/$n.tmod ]] || die "$n is not installed (no $mdir/$n.tmod)"
|
||||
done
|
||||
list=$(printf '%s\n' "${current[@]}" "$@" | sort -u)
|
||||
else
|
||||
list=$(printf '%s\n' "${current[@]}" | grep -vxF -f <(printf '%s\n' "$@") || true)
|
||||
fi
|
||||
mkdir -p "$mdir"
|
||||
# shellcheck disable=SC2086
|
||||
write_enabled "$mdir" $list
|
||||
}
|
||||
|
||||
mods_remove() {
|
||||
[[ $# -gt 0 ]] || die "no mod names given"
|
||||
local n
|
||||
for n in "$@"; do
|
||||
[[ $n =~ ^[A-Za-z0-9_]+$ ]] || die "invalid mod name '$n'"
|
||||
if [[ -f $mdir/$n.tmod ]]; then rm -f "$mdir/$n.tmod"; echo "removed $n"; else log "$n was not installed"; fi
|
||||
done
|
||||
mods_set 0 "$@"
|
||||
}
|
||||
|
||||
cmd_mods() {
|
||||
local name=$1; shift
|
||||
local action=${1:-list}
|
||||
if [[ $# -gt 0 ]]; then shift; fi
|
||||
local dir mdir
|
||||
dir=$(inst_dir "$name")
|
||||
[[ -f $dir/instance.conf ]] || die "no instance '$name' (create it with: terractl new $name tmodloader)"
|
||||
load_conf "$name"
|
||||
[[ $TYPE == tmodloader ]] || die "'$name' is a $TYPE instance; mods need TYPE=tmodloader"
|
||||
mdir="$dir/data/Mods"
|
||||
case $action in
|
||||
list) mods_list ;;
|
||||
add) mods_add "$@" ;;
|
||||
enable) mods_set 1 "$@"; echo "enabled: $*"; mods_restart_hint ;;
|
||||
disable) mods_set 0 "$@"; echo "disabled: $*"; mods_restart_hint ;;
|
||||
remove) mods_remove "$@"; mods_restart_hint ;;
|
||||
*) die "unknown mods action '$action' (list, add, enable, disable, remove)" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
cmd_new() {
|
||||
local name=$1 type=${2:-vanilla} port=${3:-} dir
|
||||
[[ $type == vanilla || $type == tmodloader ]] || die "type must be vanilla or tmodloader"
|
||||
[[ -z $port || $port =~ ^[0-9]+$ ]] || die "port must be a number"
|
||||
dir=$(inst_dir "$name")
|
||||
[[ -e $dir ]] && die "$dir already exists"
|
||||
mkdir -p "$dir/data/Worlds"
|
||||
if [[ $type == tmodloader ]]; then mkdir -p "$dir/data/Mods"; fi
|
||||
write_instance_conf "$name" "$type"
|
||||
write_server_config "$name" "$port"
|
||||
if [[ $EUID -eq 0 ]]; then chown -R "$SVC_USER:$SVC_USER" "$dir"; fi
|
||||
cat <<EOF
|
||||
Created $type instance '$name' in $dir
|
||||
Review $dir/serverconfig.txt, then:
|
||||
EOF
|
||||
if [[ $type == tmodloader ]]; then
|
||||
echo " terractl mods $name add <folder or .tmod files> (before first start)"
|
||||
fi
|
||||
cat <<EOF
|
||||
systemctl enable --now terraria@$name
|
||||
terractl attach $name
|
||||
EOF
|
||||
}
|
||||
|
||||
cmd_list() {
|
||||
local d n port state
|
||||
printf '%-20s %-11s %-6s %s\n' INSTANCE TYPE PORT STATE
|
||||
for d in "$BASE_DIR"/*/; do
|
||||
[[ -f $d/instance.conf ]] || continue
|
||||
n=$(basename "$d")
|
||||
(
|
||||
load_conf "$n"
|
||||
port=$(sed -n 's/^port=\([0-9][0-9]*\).*/\1/p' "$d/serverconfig.txt" 2>/dev/null | tail -n1)
|
||||
state=$(systemctl is-active "terraria@$n" 2>/dev/null || true)
|
||||
printf '%-20s %-11s %-6s %s\n' "$n" "$TYPE" "${port:--}" "$state"
|
||||
) 2>/dev/null || printf '%-20s %s\n' "$n" "(cannot read config - try sudo)"
|
||||
done
|
||||
}
|
||||
|
||||
cmd_reinstall() {
|
||||
local name=$1 clean=0 dir; dir=$(inst_dir "$name")
|
||||
shift
|
||||
case ${1:-} in
|
||||
--clean) clean=1 ;;
|
||||
"") ;;
|
||||
*) die "unknown option '$1'" ;;
|
||||
esac
|
||||
[[ -f $dir/instance.conf ]] || die "no instance '$name'"
|
||||
rm -f "$dir/server/.installed"
|
||||
if [[ $clean -eq 1 ]]; then
|
||||
rm -rf "$dir/server/dotnet" "$dir/server/tModLoader-Logs"
|
||||
echo "removed the downloaded .NET runtime and logs; .NET will be downloaded again"
|
||||
fi
|
||||
echo "server/ will be re-unpacked on next start: systemctl restart terraria@$name"
|
||||
}
|
||||
|
||||
[[ $# -ge 1 ]] || usage
|
||||
command=$1; shift
|
||||
case $command in
|
||||
-h|--help|help) usage 0 ;;
|
||||
-V|--version|version)
|
||||
if [[ $TERRACTL_VERSION == "@"VERSION"@" ]]; then echo "terractl (development copy)"
|
||||
else echo "terractl $TERRACTL_VERSION"; fi
|
||||
exit 0 ;;
|
||||
list) cmd_list; exit ;;
|
||||
esac
|
||||
[[ $# -ge 1 ]] || usage
|
||||
valid_name "$1"
|
||||
|
||||
# prepare/run create and modify instance files. If invoked as root (e.g. while
|
||||
# debugging), switch to the service user so nothing ends up owned by root.
|
||||
if [[ $command == prepare || $command == run ]]; then
|
||||
if [[ $EUID -eq 0 ]]; then
|
||||
command -v runuser >/dev/null || die "runuser not found; run as '$SVC_USER' instead"
|
||||
exec runuser -u "$SVC_USER" -- env HOME="$(inst_dir "$1")" \
|
||||
"$(readlink -f "$0")" "$command" "$@"
|
||||
elif [[ $(id -un) != "$SVC_USER" ]]; then
|
||||
die "$command must run as root or '$SVC_USER'"
|
||||
fi
|
||||
fi
|
||||
|
||||
case $command in
|
||||
prepare) cmd_prepare "$1" ;;
|
||||
run) cmd_run "$1" ;;
|
||||
stop) cmd_stop "$@" ;;
|
||||
cmd) cmd_cmd "$@" ;;
|
||||
attach) cmd_attach "$1" ;;
|
||||
mods) cmd_mods "$@" ;;
|
||||
new) cmd_new "$@" ;;
|
||||
reinstall) cmd_reinstall "$@" ;;
|
||||
delete) cmd_delete "$@" ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
@@ -0,0 +1,14 @@
|
||||
# Overrides for terractl. Uncomment to change.
|
||||
# Changes apply to new commands and to each instance's next start.
|
||||
|
||||
# Where instances live. If you change this, the service must also be allowed
|
||||
# to write there: sudo systemctl edit terraria@.service
|
||||
# [Service]
|
||||
# ReadWritePaths=/your/new/path
|
||||
#TERRARIA_BASE=/var/lib/terraria/instances
|
||||
|
||||
# Where server zips are read from
|
||||
#TERRARIA_DIST=/var/lib/terraria/dist
|
||||
|
||||
# Seconds to wait for a server to save and exit on stop (keep below 90)
|
||||
#TERRARIA_STOP_TIMEOUT=80
|
||||
@@ -0,0 +1,2 @@
|
||||
# System user the Terraria servers run as
|
||||
u terraria - "Terraria game servers" /var/lib/terraria /usr/sbin/nologin
|
||||
@@ -0,0 +1,5 @@
|
||||
# No age field: nothing here is ever cleaned up automatically.
|
||||
d /var/lib/terraria 0750 terraria terraria -
|
||||
d /var/lib/terraria/instances 0750 terraria terraria -
|
||||
# setgid + group-writable: members of the 'terraria' group can upload zips
|
||||
d /var/lib/terraria/dist 2775 root terraria -
|
||||
+698
@@ -0,0 +1,698 @@
|
||||
# terraria-server-manager
|
||||
|
||||
Run any number of vanilla Terraria and tModLoader dedicated servers on one Linux host, each managed as its own systemd service, with the `terractl` command.
|
||||
|
||||
## Contents
|
||||
|
||||
1. [Overview](#overview)
|
||||
2. [Components](#components)
|
||||
3. [Directory layout](#directory-layout)
|
||||
4. [Installation](#installation)
|
||||
5. [Migrating from the manual install](#migrating-from-the-manual-install)
|
||||
6. [Server zips](#server-zips)
|
||||
7. [Creating an instance](#creating-an-instance)
|
||||
8. [Starting, stopping and boot](#starting-stopping-and-boot)
|
||||
9. [The server console](#the-server-console)
|
||||
10. [Configuration](#configuration)
|
||||
11. [Mods (tModLoader)](#mods-tmodloader)
|
||||
12. [Versions and upgrades](#versions-and-upgrades)
|
||||
13. [Deleting and restoring](#deleting-and-restoring)
|
||||
14. [Backups](#backups)
|
||||
15. [Permissions](#permissions)
|
||||
16. [Running by hand for debugging](#running-by-hand-for-debugging)
|
||||
17. [Troubleshooting](#troubleshooting)
|
||||
18. [Command reference](#command-reference)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Each server is an **instance** with a short name such as `survival` or `calamity`. An instance is:
|
||||
|
||||
- a directory, `/var/lib/terraria/instances/<name>/`, holding everything about that server;
|
||||
- a systemd service, `terraria@<name>.service`, that runs it as the unprivileged `terraria` user.
|
||||
|
||||
Server software is never installed by hand. You drop the official zips into `/var/lib/terraria/dist`, and the first time an instance starts, the right zip is unpacked into that instance's `server/` folder. Worlds, mods and settings live separately in `data/`, so server software can be replaced or upgraded without touching them.
|
||||
|
||||
Each instance chooses between **vanilla** and **tModLoader** independently, so both kinds can run side by side on different ports.
|
||||
|
||||
---
|
||||
|
||||
## Components
|
||||
|
||||
Everything is installed by the `terraria-server-manager` package:
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `/usr/lib/systemd/system/terraria@.service` | Template service. One copy runs per instance. |
|
||||
| `/usr/lib/systemd/system/terraria@.socket` | Creates the console pipe the server reads commands from. |
|
||||
| `/usr/bin/terractl` | Helper script. The service calls it to set up, run and stop servers; you use it for everyday management. |
|
||||
| `/etc/default/terractl` | Optional overrides (locations, stop timeout). |
|
||||
| `/usr/lib/sysusers.d/terractl.conf` | Defines the `terraria` system user. |
|
||||
| `/usr/lib/tmpfiles.d/terractl.conf` | Creates `/var/lib/terraria` and its subdirectories. |
|
||||
| `/usr/share/terraria-server-manager/export-tmod-mods.ps1` | Optional. Copy to your Windows PC to collect your enabled tModLoader mods. |
|
||||
|
||||
What happens when an instance starts:
|
||||
|
||||
1. systemd creates the console pipe `/run/terraria/<name>.stdin` (from the socket unit).
|
||||
2. `terractl prepare <name>` creates the instance directory and default configs if they're missing, and unpacks the server zip if it hasn't been unpacked yet.
|
||||
3. `terractl run <name>` starts the vanilla or tModLoader server, reading commands from the pipe and logging to the journal.
|
||||
|
||||
On stop, `terractl stop` sends `exit` through the pipe so the world is saved before the server shuts down.
|
||||
|
||||
---
|
||||
|
||||
## Directory layout
|
||||
|
||||
```
|
||||
/var/lib/terraria/ Home of the 'terraria' user
|
||||
├── dist/ Server zips (you add these; never modified)
|
||||
│ ├── terraria-server-1458.zip
|
||||
│ └── tModLoader-v2026.07.3.0.zip
|
||||
└── instances/
|
||||
├── <name>/ One directory per instance
|
||||
│ ├── instance.conf Which server type and zip to use
|
||||
│ ├── serverconfig.txt Terraria's own settings (port, world, password...)
|
||||
│ ├── server/ Unpacked server software (replaceable)
|
||||
│ │ └── .installed Records what was unpacked
|
||||
│ └── data/ Your data (never touched by upgrades)
|
||||
│ ├── Worlds/ World files
|
||||
│ ├── Mods/ tModLoader mods + enabled.json
|
||||
│ ├── ModConfigs/ tModLoader mod settings
|
||||
│ └── banlist.txt
|
||||
└── .deleted/ Backups made by 'terractl delete'
|
||||
|
||||
/run/terraria/<name>.stdin Console pipe (exists only while running)
|
||||
```
|
||||
|
||||
The server can write only inside `instances/`. It reads `dist/` but can never change it.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
The package works on Debian 12 or newer and Ubuntu 22.04 or newer.
|
||||
|
||||
```bash
|
||||
sudo apt install ./terraria-server-manager_2.0.0_all.deb
|
||||
```
|
||||
|
||||
Using `apt install ./file.deb` (rather than `dpkg -i`) installs the dependencies automatically. `unzip` is required. `curl` and the ICU library are "recommended", which apt installs by default; they're only needed for tModLoader instances, which crash with `Aborted` without ICU.
|
||||
|
||||
Installing the package:
|
||||
|
||||
- creates the `terraria` system user and group;
|
||||
- creates `/var/lib/terraria/`, `/var/lib/terraria/instances/` and `/var/lib/terraria/dist/`;
|
||||
- installs the units and helper script.
|
||||
|
||||
It doesn't start or enable anything.
|
||||
|
||||
If you use a firewall, open each instance's port for **TCP**. Terraria uses TCP only.
|
||||
|
||||
```bash
|
||||
sudo ufw allow 7777/tcp # example with ufw
|
||||
```
|
||||
|
||||
### Upgrading the package
|
||||
|
||||
```bash
|
||||
sudo apt install ./terraria-server-manager_<new-version>_all.deb
|
||||
```
|
||||
|
||||
Running servers are not restarted. Each one picks up the new version the next time it starts. Your changes to `/etc/default/terractl` are kept; if the new package also changes that file, dpkg asks which version to keep.
|
||||
|
||||
### Removing the package
|
||||
|
||||
```bash
|
||||
sudo apt remove terraria-server-manager # stops all instances (worlds are saved first)
|
||||
sudo apt purge terraria-server-manager # also removes config and boot-time enablement
|
||||
```
|
||||
|
||||
Neither command deletes `/var/lib/terraria` or the `terraria` user, so your worlds survive even a purge. To delete everything:
|
||||
|
||||
```bash
|
||||
sudo rm -rf /var/lib/terraria && sudo userdel terraria
|
||||
```
|
||||
|
||||
### Upgrading from the `terraria-instance` package
|
||||
|
||||
Versions before 2.0.0 were packaged as `terraria-instance`, and the command had the same name. The new package declares that it replaces the old one, so installing it removes `terraria-instance` automatically:
|
||||
|
||||
```bash
|
||||
sudo apt install ./terraria-server-manager_2.0.0_all.deb
|
||||
```
|
||||
|
||||
Things to know:
|
||||
|
||||
- **Instances are stopped (and saved) during the switch**, because removing the old package stops them. Start them again afterwards with `sudo systemctl start terraria@<name>`. Instances, worlds, mods and the `terraria` user all carry over unchanged.
|
||||
- **The command is now `terractl`.** Update any scripts or cron jobs that call `terraria-instance`.
|
||||
|
||||
**Clean up the old package straight after upgrading.** The old package is removed, but dpkg still keeps a record of it because its settings file is left behind. Purging that record, including with a general cleanup like `apt purge ~c` or `apt autoremove --purge`, runs the old package's purge step. Because the instances have the same unit names, that step deletes their start-at-boot links. So purge it deliberately, then re-enable the instances:
|
||||
|
||||
```bash
|
||||
# 1. If you customised it, copy the old settings file across (purging deletes it)
|
||||
sudo cp /etc/default/terraria-instance /etc/default/terractl
|
||||
|
||||
# 2. Note which instances start at boot, purge the old package, re-enable them
|
||||
enabled=$(ls /etc/systemd/system/multi-user.target.wants/ | grep '^terraria@')
|
||||
sudo apt purge terraria-instance
|
||||
for unit in $enabled; do sudo systemctl enable "$unit"; done
|
||||
```
|
||||
|
||||
### Building from source
|
||||
|
||||
From the git repository:
|
||||
|
||||
```bash
|
||||
make check # syntax-check the scripts (and shellcheck, if installed)
|
||||
make deb # builds build/terraria-server-manager_<version>_all.deb
|
||||
sudo make install # or install directly without a package (PREFIX=/usr/local)
|
||||
sudo make uninstall # removes a 'make install' (keeps settings and data)
|
||||
```
|
||||
|
||||
The version comes from the `VERSION` file. Set `Maintainer:` in `packaging/deb/control` to yourself before publishing packages.
|
||||
|
||||
### Changing locations
|
||||
|
||||
`/etc/default/terractl` can move the instances or zips directory. If you move the instances directory, the service also needs permission to write to the new location:
|
||||
|
||||
```bash
|
||||
sudo systemctl edit terraria@.service
|
||||
```
|
||||
```ini
|
||||
[Service]
|
||||
ReadWritePaths=/your/new/path
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migrating from the manual install
|
||||
|
||||
The earlier manual setup used `/opt/terraria`, `/terraria`, and a script called `terraria-instance` in `/usr/local/bin`. To move to the package:
|
||||
|
||||
```bash
|
||||
# 1. Note which instances start at boot, then stop everything
|
||||
ls /etc/systemd/system/multi-user.target.wants/ | grep terraria@
|
||||
sudo systemctl stop 'terraria@*'
|
||||
|
||||
# 2. Remove the old files. These would override the package's versions:
|
||||
# /etc/systemd/system takes precedence over /usr/lib/systemd/system, and
|
||||
# /usr/local/bin comes before /usr/bin in PATH.
|
||||
sudo rm /etc/systemd/system/terraria@.service /etc/systemd/system/terraria@.socket
|
||||
sudo rm /usr/local/bin/terraria-instance
|
||||
|
||||
# 3. Install the package (keeps the existing 'terraria' user)
|
||||
sudo apt install ./terraria-server-manager_2.0.0_all.deb
|
||||
|
||||
# 4. Move instances and zips
|
||||
for d in /opt/terraria/*/; do
|
||||
[ -f "$d/instance.conf" ] && sudo mv "$d" /var/lib/terraria/instances/
|
||||
done
|
||||
[ -d /opt/terraria/.deleted ] && sudo mv /opt/terraria/.deleted /var/lib/terraria/instances/
|
||||
sudo mv /terraria/*.zip /var/lib/terraria/dist/
|
||||
|
||||
# 5. serverconfig.txt contains absolute paths; update them
|
||||
sudo sed -i 's#/opt/terraria/#/var/lib/terraria/instances/#g' \
|
||||
/var/lib/terraria/instances/*/serverconfig.txt
|
||||
sudo chown -R terraria:terraria /var/lib/terraria/instances
|
||||
|
||||
# 6. Re-enable instances (the old boot links point at the deleted unit file)
|
||||
sudo systemctl reenable terraria@calamity # repeat for each instance from step 1
|
||||
sudo systemctl start terraria@calamity
|
||||
```
|
||||
|
||||
Once everything works, remove the old directories: `sudo rm -rf /terraria /opt/terraria`. If you set `ZIP=` in an `instance.conf` to an absolute path under `/terraria`, change it to just the file name.
|
||||
|
||||
The existing `terraria` user keeps `/opt/terraria` as its home directory. That's harmless, because the script sets a per-instance home itself. To tidy it anyway, run `sudo usermod -d /var/lib/terraria terraria` after deleting `/opt/terraria`.
|
||||
|
||||
---
|
||||
|
||||
## Server zips
|
||||
|
||||
Put the official downloads in `/var/lib/terraria/dist`:
|
||||
|
||||
| Type | File name pattern | Where to get it |
|
||||
|---|---|---|
|
||||
| Vanilla | `terraria-server-*.zip` | terraria.org (the "PC Dedicated Server" download link) |
|
||||
| tModLoader | `tModLoader*.zip` | tModLoader's GitHub releases page (`tModLoader.zip`) |
|
||||
|
||||
**Rename tModLoader zips to include the version**, for example `tModLoader-v2026.07.3.0.zip`. GitHub always calls the file `tModLoader.zip`, so renaming it lets you keep several versions side by side and pin instances to specific ones.
|
||||
|
||||
Zips are only read, never changed, and one zip can serve any number of instances.
|
||||
|
||||
**tModLoader zips don't include .NET.** Each tModLoader instance downloads the .NET runtime it needs into `server/dotnet/` on its first start, so that start needs internet access and `curl`. The download is kept when you upgrade or re-unpack the instance. tModLoader checks the .NET version on every start and fetches a different one itself if a new release needs it.
|
||||
|
||||
Members of the `terraria` group can copy zips into `dist/` without `sudo`:
|
||||
|
||||
```bash
|
||||
scp tModLoader-v2026.07.3.0.zip you@yourserver:/var/lib/terraria/dist/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Creating an instance
|
||||
|
||||
```bash
|
||||
sudo terractl new <name> [vanilla|tmodloader] [port]
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
sudo terractl new survival # vanilla, next free port
|
||||
sudo terractl new calamity tmodloader # tModLoader, next free port
|
||||
sudo terractl new pvp vanilla 7800 # vanilla on port 7800
|
||||
```
|
||||
|
||||
If you don't give a port, the instance gets the next number after the highest port already used, starting at 7777.
|
||||
|
||||
This creates `/var/lib/terraria/instances/<name>/` with `instance.conf` and `serverconfig.txt`, but **does not start the server**. Before the first start, review `serverconfig.txt` (world size, difficulty, password). For a tModLoader instance, also add your mods first (see [Mods](#mods-tmodloader)): the world is generated on first start, and it should be generated with the mods loaded so their world content is created.
|
||||
|
||||
Instance names may contain letters, digits, `_`, `-` and `.`, and must not start with `.`.
|
||||
|
||||
### Quick start: a new modded server
|
||||
|
||||
```bash
|
||||
sudo terractl new calamity tmodloader
|
||||
sudo terractl mods calamity add ~/tmod-export # your mods, before the first start
|
||||
sudo nano /var/lib/terraria/instances/calamity/serverconfig.txt # optional: world size, difficulty
|
||||
sudo systemctl enable --now terraria@calamity
|
||||
sudo terractl attach calamity # watch it load and generate the world
|
||||
```
|
||||
|
||||
You can also skip `new` and just run `sudo systemctl start terraria@<name>`. That creates a **vanilla** instance with default settings; to switch it to tModLoader, change `TYPE` in its `instance.conf` and restart.
|
||||
|
||||
---
|
||||
|
||||
## Starting, stopping and boot
|
||||
|
||||
Instances are ordinary systemd services:
|
||||
|
||||
```bash
|
||||
sudo systemctl start terraria@calamity
|
||||
sudo systemctl stop terraria@calamity # saves the world, then exits
|
||||
sudo systemctl restart terraria@calamity
|
||||
sudo systemctl enable terraria@calamity # start at boot
|
||||
sudo systemctl disable terraria@calamity # don't start at boot
|
||||
sudo systemctl enable --now terraria@calamity # both at once
|
||||
systemctl status terraria@calamity
|
||||
```
|
||||
|
||||
To see all instances with their type, port and state:
|
||||
|
||||
```bash
|
||||
sudo terractl list
|
||||
```
|
||||
|
||||
Behaviour to be aware of:
|
||||
|
||||
- **Crashes restart automatically** after 10 seconds.
|
||||
- **A clean exit does not restart.** If you type `exit` in the server console, the server saves and stops, and stays stopped until you start it again.
|
||||
- **The first start of a tModLoader instance is slow.** It downloads .NET, loads the mods and generates the world, which can take several minutes.
|
||||
- **Stopping waits for the save.** The server gets up to 80 seconds to save and exit before systemd forces it to stop.
|
||||
|
||||
---
|
||||
|
||||
## The server console
|
||||
|
||||
Each server reads console commands (`save`, `say`, `kick`, `playing`, and so on) from its pipe, and its output goes to the systemd journal.
|
||||
|
||||
### Interactive: `attach`
|
||||
|
||||
```bash
|
||||
sudo terractl attach calamity
|
||||
```
|
||||
|
||||
This shows the last 30 lines of output and then follows it live. Anything you type is sent to the server, with arrow-key editing and history. **Ctrl-D** or **Ctrl-C** detaches, and the server keeps running.
|
||||
|
||||
`attach` will not send `exit`, since that would stop the server. Use `systemctl stop` to stop it.
|
||||
|
||||
The one visible difference from a real terminal: the server's `:` prompt only appears once a newline follows it.
|
||||
|
||||
### One-off commands: `cmd`
|
||||
|
||||
```bash
|
||||
sudo terractl cmd calamity save
|
||||
sudo terractl cmd calamity say Restarting in 5 minutes
|
||||
```
|
||||
|
||||
This is useful in scripts and cron jobs. The command's output appears in the journal.
|
||||
|
||||
### Logs only
|
||||
|
||||
```bash
|
||||
journalctl -u terraria@calamity -f # follow
|
||||
journalctl -u terraria@calamity -n 100 # last 100 lines
|
||||
journalctl -u terraria@calamity --since today
|
||||
```
|
||||
|
||||
tModLoader also writes its own logs to `/var/lib/terraria/instances/<name>/server/tModLoader-Logs/`:
|
||||
|
||||
- `Launch.log` covers the .NET download and startup.
|
||||
- `server.log` covers mod loading and gameplay.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Each instance has two config files for two different purposes.
|
||||
|
||||
### `serverconfig.txt`: Terraria's settings
|
||||
|
||||
This is Terraria's standard server config, read by the game itself. The generated default:
|
||||
|
||||
```ini
|
||||
world=/var/lib/terraria/instances/<name>/data/Worlds/<name>.wld
|
||||
worldpath=/var/lib/terraria/instances/<name>/data/Worlds
|
||||
autocreate=2 # create world if missing: 1=small 2=medium 3=large
|
||||
worldname=<name>
|
||||
difficulty=0 # 0=classic 1=expert 2=master 3=journey
|
||||
#seed=
|
||||
port=7777
|
||||
maxplayers=8
|
||||
password=
|
||||
motd=Welcome to <name>
|
||||
secure=1
|
||||
upnp=0
|
||||
language=en-US
|
||||
banlist=/var/lib/terraria/instances/<name>/data/banlist.txt
|
||||
```
|
||||
|
||||
`autocreate`, `worldname`, `difficulty` and `seed` only matter when the world file doesn't exist yet. Once the world has been created, changing them has no effect.
|
||||
|
||||
Changes take effect on restart. Every instance must use a different `port`.
|
||||
|
||||
To use an existing world, copy the `.wld` file (plus `.twld` for tModLoader worlds) into `data/Worlds/` and point `world=` at it.
|
||||
|
||||
### `instance.conf`: how the instance runs
|
||||
|
||||
The game never reads this file. The helper script reads it at every start.
|
||||
|
||||
```bash
|
||||
TYPE=tmodloader # vanilla | tmodloader
|
||||
ZIP= # zip to use; blank = newest matching zip at first install
|
||||
EXTRA_ARGS=() # extra server arguments, as a bash array
|
||||
```
|
||||
|
||||
| Setting | Effect |
|
||||
|---|---|
|
||||
| `TYPE` | Which server software runs. Changing it re-unpacks `server/` from the other kind of zip on the next start. `data/` and `serverconfig.txt` are kept. |
|
||||
| `ZIP` | Pins a specific zip from `/var/lib/terraria/dist` (a file name, or an absolute path). Changing it re-unpacks `server/` on the next start. If left blank, the newest matching zip is used at first install and **never upgraded automatically**. |
|
||||
| `EXTRA_ARGS` | Extra command-line arguments, e.g. `EXTRA_ARGS=(-disableannouncementbox)`. |
|
||||
|
||||
This file also marks the directory as an instance: `list` ignores directories without one, and `delete` refuses to remove them.
|
||||
|
||||
---
|
||||
|
||||
## Mods (tModLoader)
|
||||
|
||||
Mods only work on tModLoader instances. Each instance has its own separate set of mods:
|
||||
|
||||
```
|
||||
/var/lib/terraria/instances/<name>/data/Mods/
|
||||
├── enabled.json ["CalamityMod", "MagicStorage"]
|
||||
├── CalamityMod.tmod
|
||||
└── MagicStorage.tmod
|
||||
```
|
||||
|
||||
`enabled.json` lists which mods to load, using each file's name without `.tmod`. Every mod's dependencies must also be present and listed. Mod settings live in `data/ModConfigs/`.
|
||||
|
||||
You don't need to edit these by hand: `terractl mods` manages them and sets the file ownership correctly (see [Managing mods](#managing-mods)). The `Mods` folder is created automatically for tModLoader instances.
|
||||
|
||||
Players don't need to install mods themselves: tModLoader offers to download the server's mods when they join.
|
||||
|
||||
### Exporting mods from Windows
|
||||
|
||||
tModLoader on Windows keeps its files in these places:
|
||||
|
||||
| What | Where |
|
||||
|---|---|
|
||||
| Which mods are enabled | `Documents\My Games\Terraria\tModLoader\Mods\enabled.json` |
|
||||
| Workshop mods | `<Steam library>\steamapps\workshop\content\1281930\<id>\` |
|
||||
| Manually installed mods | `Documents\My Games\Terraria\tModLoader\Mods\` |
|
||||
| Mod settings | `Documents\My Games\Terraria\tModLoader\ModConfigs\` |
|
||||
|
||||
Workshop mods often contain **several builds**, in folders named after the tModLoader version they target (`2024.3`, `2025.3`, ...). The game loads the build from the highest folder that isn't newer than the running tModLoader.
|
||||
|
||||
`export-tmod-mods.ps1` applies the same rule. It's installed on the server at `/usr/share/terraria-server-manager/`; copy it to your PC first. It collects every enabled mod, a matching `enabled.json` and your mod settings into `Desktop\tmod-export`, laid out like the server's `data/` folder. Run it in PowerShell:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\export-tmod-mods.ps1 -MaxVersion 2026.7
|
||||
```
|
||||
|
||||
Set `-MaxVersion` to your tModLoader version as year.month. The full version is shown at the bottom-left of tModLoader's main menu, e.g. `v2026.07.3.0` → `2026.7`. For the preview branch, add `-TmlDir "$HOME\Documents\My Games\Terraria\tModLoader-preview"`.
|
||||
|
||||
The script prints which file it chose for each mod, and warns about any it couldn't find. It leaves missing mods out of `enabled.json`, because one missing file would stop the server loading any mods.
|
||||
|
||||
### Copying mods to the server
|
||||
|
||||
Copy the export folder to the server. `scp` is built into Windows 10 and 11:
|
||||
|
||||
```powershell
|
||||
scp -r "$HOME\Desktop\tmod-export" you@yourserver:
|
||||
```
|
||||
|
||||
This puts it in your home directory on the server. Then install the mods into the instance:
|
||||
|
||||
```bash
|
||||
sudo terractl mods calamity add ~/tmod-export
|
||||
sudo systemctl restart terraria@calamity # only if it's already running
|
||||
```
|
||||
|
||||
`add` copies the `.tmod` files and any `ModConfigs`, gives them to the `terraria` user, and adds them to `enabled.json`. For a new instance, do this **before the first start** so the world is generated with the mods loaded.
|
||||
|
||||
### Managing mods
|
||||
|
||||
```bash
|
||||
terractl mods <name> # list installed mods and whether each is enabled
|
||||
terractl mods <name> add <folder|file.tmod>... # install and enable
|
||||
terractl mods <name> disable <Mod>... # keep installed, but don't load
|
||||
terractl mods <name> enable <Mod>... # load again
|
||||
terractl mods <name> remove <Mod>... # uninstall
|
||||
```
|
||||
|
||||
`add` accepts `.tmod` files, a folder containing them, or an export folder with `Mods/` and `ModConfigs/` subfolders. Mod names are the file names without `.tmod`, e.g. `CalamityModMusic`.
|
||||
|
||||
Adding a mod that's already installed replaces it, which is how you update a mod. Changes take effect when the server restarts; the command reminds you if it's running.
|
||||
|
||||
The list also flags mods that are **enabled but missing**, because the server won't load any mods while one is missing.
|
||||
|
||||
`add` doesn't check dependencies, so remember to add them too. If one is missing, `server/tModLoader-Logs/server.log` says which.
|
||||
|
||||
---
|
||||
|
||||
## Versions and upgrades
|
||||
|
||||
### Players must match the server's version
|
||||
|
||||
- **Vanilla:** players need the same Terraria version as the server's zip. For example, `terraria-server-1458.zip` is Terraria 1.4.5.8.
|
||||
- **tModLoader:** players need exactly the same tModLoader version, including the same branch (stable or preview). Steam updates players' tModLoader automatically, usually monthly, so a server that was matching can become mismatched after an update.
|
||||
|
||||
Stable tModLoader and the latest vanilla Terraria can be based on different Terraria versions. That's fine, because every instance runs independently.
|
||||
|
||||
### Upgrading (or downgrading) an instance
|
||||
|
||||
1. Put the new zip in `/var/lib/terraria/dist`, with the version in its name.
|
||||
2. Set `ZIP=` to that file name in the instance's `instance.conf`.
|
||||
3. Restart the instance:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart terraria@calamity
|
||||
```
|
||||
|
||||
The helper sees that the zip has changed and re-unpacks `server/`. Nothing in `data/` is touched. To move back, set `ZIP=` to the old file.
|
||||
|
||||
**Pin `ZIP=` on modded instances.** Pinned instances never change version by accident, and you can see at a glance which version each one is on. New tModLoader versions can break older mods, so only upgrade a modded server once its mods support the new version.
|
||||
|
||||
### Re-unpacking the same version
|
||||
|
||||
If `server/` gets damaged or you want a clean copy:
|
||||
|
||||
```bash
|
||||
sudo terractl reinstall calamity
|
||||
sudo systemctl restart terraria@calamity
|
||||
```
|
||||
|
||||
For tModLoader, the downloaded .NET runtime and `tModLoader-Logs/` are kept. If you suspect .NET itself is damaged, use `--clean` to delete them too, so .NET is downloaded again on the next start:
|
||||
|
||||
```bash
|
||||
sudo terractl reinstall calamity --clean
|
||||
sudo systemctl restart terraria@calamity
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deleting and restoring
|
||||
|
||||
```bash
|
||||
sudo terractl delete calamity # asks you to type the name
|
||||
sudo terractl delete calamity -y # no prompt
|
||||
sudo terractl delete calamity -y --no-backup # permanently, no backup
|
||||
```
|
||||
|
||||
This stops and disables the service, then removes the whole instance directory. Unless you pass `--no-backup`, it first archives the configs and `data/` to `/var/lib/terraria/instances/.deleted/<name>-<timestamp>.tar.gz`. `server/` is left out of the archive because it can be recreated from the zip.
|
||||
|
||||
To restore an instance:
|
||||
|
||||
```bash
|
||||
sudo tar -xzf /var/lib/terraria/instances/.deleted/calamity-20260928-134422.tar.gz -C /var/lib/terraria/instances
|
||||
sudo chown -R terraria:terraria /var/lib/terraria/instances/calamity
|
||||
sudo systemctl enable --now terraria@calamity # server/ is unpacked again on start
|
||||
```
|
||||
|
||||
`.deleted/` is never cleaned up automatically. Remove old archives yourself.
|
||||
|
||||
---
|
||||
|
||||
## Backups
|
||||
|
||||
Everything worth keeping is in `data/`, `instance.conf` and `serverconfig.txt`.
|
||||
|
||||
To back up a running server, save it first so the world file on disk is current:
|
||||
|
||||
```bash
|
||||
sudo terractl cmd calamity save
|
||||
sleep 10
|
||||
sudo tar -czf /root/calamity-$(date +%F).tar.gz -C /var/lib/terraria/instances \
|
||||
--exclude=calamity/server calamity
|
||||
```
|
||||
|
||||
Stopping the server first is the safest option. Terraria also keeps a `.bak` copy of each world alongside the world file.
|
||||
|
||||
---
|
||||
|
||||
## Permissions
|
||||
|
||||
The servers always run as the `terraria` user. Administration is simplest with `sudo`.
|
||||
|
||||
| Command | Who can run it | Notes |
|
||||
|---|---|---|
|
||||
| `new` | root (or `terraria`) | Run as root, it sets ownership of the new instance to `terraria` itself. |
|
||||
| `list` | root, or members of the `terraria` group | `/var/lib/terraria` is not readable by other users. |
|
||||
| Adding zips to `dist/` | root, or members of the `terraria` group | |
|
||||
| `attach` | root, or members of **both** `terraria` and `systemd-journal` | Needs to write to the console pipe and read the journal. |
|
||||
| `cmd` | root, or members of the `terraria` group | Writes to the console pipe. |
|
||||
| `reinstall` | root (or `terraria`) | |
|
||||
| `mods <name>` (list) | root, or members of the `terraria` group | |
|
||||
| `mods add/enable/disable/remove` | root (or `terraria`) | Run as root, it sets ownership of the files it installs to `terraria`. |
|
||||
| `delete` | root only | Checked by the script. |
|
||||
| `prepare`, `run` | Normally only the service | Run as root, they automatically switch to the `terraria` user, so no files end up owned by root. Any other user is refused. |
|
||||
| `stop` | Only the service | Needs the server's process ID from systemd. |
|
||||
| `systemctl start/stop/enable` | root | |
|
||||
| `journalctl -u terraria@...` | root, or members of `systemd-journal` or `adm` | |
|
||||
|
||||
To run the everyday commands without `sudo`:
|
||||
|
||||
```bash
|
||||
sudo usermod -aG terraria,systemd-journal youruser # then log out and back in
|
||||
```
|
||||
|
||||
Members of the `terraria` group can read server files (useful for backups) but cannot change them. Files the service creates are readable, not writable, by the group.
|
||||
|
||||
### Avoiding root-owned files
|
||||
|
||||
The helper commands don't leave root-owned files behind, and `mods add` handles mods and ModConfigs. The remaining risk is **copying or creating other files in an instance directory as root**, such as worlds. The server can't write to root-owned files, so saving fails or tModLoader errors. After copying anything in, run:
|
||||
|
||||
```bash
|
||||
sudo chown -R terraria:terraria /var/lib/terraria/instances/<name>
|
||||
```
|
||||
|
||||
Alternatively, copy files as the `terraria` user to begin with:
|
||||
|
||||
```bash
|
||||
sudo -u terraria cp MyWorld.wld /var/lib/terraria/instances/calamity/data/Worlds/
|
||||
```
|
||||
|
||||
Editing an existing file with `sudo nano` keeps its owner, so it's fine.
|
||||
|
||||
---
|
||||
|
||||
## Running by hand for debugging
|
||||
|
||||
Stop the service first, so two copies of the same server don't run at once on the same port and world:
|
||||
|
||||
```bash
|
||||
sudo systemctl stop terraria@calamity
|
||||
sudo terractl run calamity # automatically runs as 'terraria'
|
||||
```
|
||||
|
||||
The server runs in your terminal and you can type commands into it directly. Type `exit` to stop it, then start the service again:
|
||||
|
||||
```bash
|
||||
sudo systemctl start terraria@calamity
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**tModLoader stops with `Aborted` right after "Launched Using Local Dotnet".**
|
||||
The ICU library is missing, so .NET aborts at startup. Fix it with `sudo apt install libicu-dev`, then restart the instance. To confirm, check that `ldconfig -p | grep -c libicu` prints more than 0.
|
||||
|
||||
**`ls: cannot access '.../dotnet/shared/Microsoft.NETCore.App'` on the first start.**
|
||||
Harmless. tModLoader looks for old .NET versions to clean up before any have been installed.
|
||||
|
||||
**The first tModLoader start fails while downloading .NET.**
|
||||
The server needs internet access and `curl` on its first start. See `server/tModLoader-Logs/Launch.log` for the exact error.
|
||||
|
||||
If the host has no internet access, you can copy the `dotnet` folder from another tModLoader instance that runs the same tModLoader version. Unpack the new instance first, then copy the folder in before starting it:
|
||||
```bash
|
||||
sudo terractl prepare <name> # unpacks server/ without starting
|
||||
sudo cp -a /var/lib/terraria/instances/other/server/dotnet /var/lib/terraria/instances/<name>/server/
|
||||
sudo systemctl start terraria@<name>
|
||||
```
|
||||
|
||||
**The server can't save, or you see "Permission denied" errors.**
|
||||
Some files are owned by root. Run `sudo chown -R terraria:terraria /var/lib/terraria/instances/<name>`.
|
||||
|
||||
**Players can't join a tModLoader server with a version-mismatch message.**
|
||||
The player's tModLoader version differs from the server's. See [Versions and upgrades](#versions-and-upgrades).
|
||||
|
||||
**Mods don't load.**
|
||||
Run `terractl mods <name>` to check for mods that are enabled but missing. Then check `server/tModLoader-Logs/server.log` for the cause. Common ones:
|
||||
- a dependency is missing from `data/Mods/`;
|
||||
- a mod name in `enabled.json` doesn't match its `.tmod` file name;
|
||||
- a mod build was made for a newer tModLoader than the server (re-export with `-MaxVersion`);
|
||||
- the files are owned by root.
|
||||
|
||||
**The server fails with "address already in use".**
|
||||
Two instances share a port, or a copy you started by hand is still running. Check with `sudo terractl list` and `pgrep -af 'tModLoader.dll|TerrariaServer'`.
|
||||
|
||||
**The service starts but you can't connect.**
|
||||
Check the port in `serverconfig.txt`, your firewall (TCP), and router port forwarding.
|
||||
|
||||
**The server isn't restarting after it stopped.**
|
||||
It exited cleanly, probably via `exit` in the console, so systemd leaves it stopped. Start it again with `systemctl start`.
|
||||
|
||||
**The world was created without mod content.**
|
||||
It was generated before the mods were added. Delete the world files in `data/Worlds/` (or point `world=` at a new name) and restart to generate a new one.
|
||||
|
||||
---
|
||||
|
||||
## Command reference
|
||||
|
||||
```
|
||||
terractl new <name> [vanilla|tmodloader] [port] Create an instance (not started)
|
||||
terractl list Show instances, type, port, state
|
||||
terractl attach <name> Interactive console (Ctrl-D detaches)
|
||||
terractl cmd <name> <command...> Send one console command
|
||||
terractl reinstall <name> [--clean] Re-unpack server/ on next start
|
||||
(--clean also re-downloads .NET)
|
||||
terractl mods <name> [list] Show installed mods
|
||||
terractl mods <name> add <folder|file.tmod>... Install and enable mods (and ModConfigs)
|
||||
terractl mods <name> enable|disable <Mod>... Change which mods load
|
||||
terractl mods <name> remove <Mod>... Uninstall mods
|
||||
terractl delete <name> [-y] [--no-backup] Stop, disable, back up and remove
|
||||
terractl --version Show the installed version
|
||||
|
||||
Used by the service:
|
||||
terractl prepare <name> Create dirs/configs, unpack zip if needed
|
||||
terractl run <name> Run the server
|
||||
terractl stop <name> <pid> Save and shut down via the console
|
||||
```
|
||||
|
||||
```
|
||||
systemctl start|stop|restart|status terraria@<name>
|
||||
systemctl enable|disable [--now] terraria@<name>
|
||||
journalctl -u terraria@<name> [-f] [-n N]
|
||||
```
|
||||
@@ -0,0 +1 @@
|
||||
/etc/default/terractl
|
||||
@@ -0,0 +1,22 @@
|
||||
Package: terraria-server-manager
|
||||
Version: @VERSION@
|
||||
Section: games
|
||||
Priority: optional
|
||||
Architecture: all
|
||||
Installed-Size: @INSTALLED_SIZE@
|
||||
Maintainer: Your Name <you@example.com>
|
||||
Depends: unzip, systemd
|
||||
Recommends: curl, libicu76 | libicu74 | libicu72 | libicu70 | libicu-dev
|
||||
Conflicts: terraria-instance
|
||||
Replaces: terraria-instance
|
||||
Description: run multiple Terraria and tModLoader dedicated servers with systemd
|
||||
Provides the terraria@.service template and the terractl command for
|
||||
running any number of vanilla Terraria and tModLoader dedicated servers, each
|
||||
as its own systemd service under an unprivileged terraria user.
|
||||
.
|
||||
Server software is not included. Place the official server zips in
|
||||
/var/lib/terraria/dist and they are unpacked into each instance on demand.
|
||||
.
|
||||
curl and ICU are only needed for tModLoader instances.
|
||||
.
|
||||
This package replaces terraria-instance (the same tool under its old name).
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
case "$1" in
|
||||
configure)
|
||||
systemd-sysusers terractl.conf
|
||||
systemd-tmpfiles --create terractl.conf
|
||||
if [ -d /run/systemd/system ]; then
|
||||
systemctl daemon-reload || true
|
||||
fi
|
||||
# Running instances are not restarted; they pick up changes on their next start.
|
||||
;;
|
||||
esac
|
||||
|
||||
exit 0
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
case "$1" in
|
||||
purge)
|
||||
# Forget which instances were enabled at boot
|
||||
rm -f /etc/systemd/system/multi-user.target.wants/terraria@*.service
|
||||
rm -rf /etc/systemd/system/terraria@.service.d
|
||||
cat >&2 <<MSG
|
||||
terraria-server-manager purged. Worlds and instances in /var/lib/terraria and the
|
||||
'terraria' user were kept. Remove them manually if they are no longer needed:
|
||||
sudo rm -rf /var/lib/terraria && sudo userdel terraria
|
||||
MSG
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$1" in
|
||||
remove|purge)
|
||||
if [ -d /run/systemd/system ]; then
|
||||
systemctl daemon-reload || true
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
exit 0
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# On removal, stop all instances while the helper script still exists, so each
|
||||
# server is sent "exit" and saves its world. (Without the script, systemd would
|
||||
# just kill them.) Upgrades leave running servers alone.
|
||||
case "$1" in
|
||||
remove|deconfigure)
|
||||
if [ -d /run/systemd/system ]; then
|
||||
units=$(systemctl list-units --plain --no-legend --all 'terraria@*.service' | awk '{print $1}')
|
||||
if [ -n "$units" ]; then
|
||||
echo "Stopping Terraria instances: $units"
|
||||
systemctl stop $units || true
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,48 @@
|
||||
[Unit]
|
||||
Description=Terraria server (%i)
|
||||
Documentation=file://@DOCDIR@/README.md
|
||||
After=network-online.target terraria@%i.socket
|
||||
Wants=network-online.target
|
||||
# The socket unit provides a FIFO used as the server's console (stdin).
|
||||
Requires=terraria@%i.socket
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=terraria
|
||||
Group=terraria
|
||||
|
||||
# Creates the instance directory and default configs, unpacks the server zip if needed
|
||||
ExecStartPre=@BINDIR@/terractl prepare %i
|
||||
# Execs the vanilla or tModLoader server depending on instance.conf
|
||||
ExecStart=@BINDIR@/terractl run %i
|
||||
# Sends "exit" to the console (saves the world) and waits for shutdown
|
||||
ExecStop=@BINDIR@/terractl stop %i $MAINPID
|
||||
|
||||
StandardInput=socket
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=terraria-%i
|
||||
|
||||
TimeoutStartSec=10min
|
||||
TimeoutStopSec=90
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
UMask=0027
|
||||
|
||||
# Hardening. Only the instances directory is writable; server zips are read-only.
|
||||
# If you move TERRARIA_BASE in /etc/default/terractl, add a drop-in
|
||||
# (systemctl edit terraria@.service) with ReadWritePaths=<new location>.
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/var/lib/terraria/instances
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
RestrictSUIDSGID=true
|
||||
LockPersonality=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,12 @@
|
||||
[Unit]
|
||||
Description=Terraria server console FIFO (%i)
|
||||
# Stopping the service also stops (and removes) its console FIFO
|
||||
PartOf=terraria@%i.service
|
||||
|
||||
[Socket]
|
||||
ListenFIFO=%t/terraria/%i.stdin
|
||||
SocketUser=terraria
|
||||
SocketGroup=terraria
|
||||
SocketMode=0660
|
||||
DirectoryMode=0755
|
||||
RemoveOnStop=true
|
||||
@@ -0,0 +1,130 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Collects every mod currently enabled in tModLoader (Windows) into one folder,
|
||||
ready to copy into /opt/terraria/<instance>/data/ on the Linux server.
|
||||
|
||||
.DESCRIPTION
|
||||
Output layout (mirrors the server's data/ folder):
|
||||
<Out>\Mods\*.tmod
|
||||
<Out>\Mods\enabled.json (only lists mods that were actually found)
|
||||
<Out>\ModConfigs\*.json
|
||||
|
||||
.tmod files are taken from Steam's Workshop folder (all Steam libraries are
|
||||
searched), falling back to Documents\My Games\Terraria\tModLoader\Mods for
|
||||
mods you installed manually or built yourself.
|
||||
|
||||
.PARAMETER Out
|
||||
Where to put the export. Default: Desktop\tmod-export
|
||||
|
||||
.PARAMETER TmlDir
|
||||
tModLoader's save folder. Default: Documents\My Games\Terraria\tModLoader
|
||||
For the preview branch use ...\My Games\Terraria\tModLoader-preview
|
||||
|
||||
.PARAMETER MaxVersion
|
||||
Optional. Your tModLoader version as year.month (e.g. 2025.3), shown at the
|
||||
bottom-left of the tModLoader main menu. Workshop builds made for a newer
|
||||
tModLoader than this are skipped.
|
||||
|
||||
.EXAMPLE
|
||||
powershell -ExecutionPolicy Bypass -File .\export-tmod-mods.ps1
|
||||
powershell -ExecutionPolicy Bypass -File .\export-tmod-mods.ps1 -MaxVersion 2025.3
|
||||
#>
|
||||
param(
|
||||
[string]$Out = (Join-Path ([Environment]::GetFolderPath('Desktop')) 'tmod-export'),
|
||||
[string]$TmlDir = (Join-Path ([Environment]::GetFolderPath('MyDocuments')) 'My Games\Terraria\tModLoader'),
|
||||
[version]$MaxVersion
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# --- Which mods are enabled? ------------------------------------------------
|
||||
$enabledJson = Join-Path $TmlDir 'Mods\enabled.json'
|
||||
if (-not (Test-Path $enabledJson)) {
|
||||
throw "Can't find $enabledJson. Has tModLoader been run? For the preview branch pass -TmlDir ...\tModLoader-preview"
|
||||
}
|
||||
$enabled = @(Get-Content $enabledJson -Raw | ConvertFrom-Json)
|
||||
if ($enabled.Count -eq 0) { throw "No mods are enabled in $enabledJson" }
|
||||
|
||||
# --- Find Steam Workshop folders for tModLoader (app 1281930) ----------------
|
||||
$steamPath = (Get-ItemProperty 'HKCU:\Software\Valve\Steam' -ErrorAction SilentlyContinue).SteamPath
|
||||
if (-not $steamPath) { $steamPath = 'C:\Program Files (x86)\Steam' }
|
||||
$libraries = @($steamPath)
|
||||
$vdf = Join-Path $steamPath 'steamapps\libraryfolders.vdf'
|
||||
if (Test-Path $vdf) {
|
||||
$libraries += Select-String -Path $vdf -Pattern '"path"\s+"([^"]+)"' |
|
||||
ForEach-Object { $_.Matches[0].Groups[1].Value -replace '\\\\', '\' }
|
||||
}
|
||||
$workshopDirs = @($libraries |
|
||||
ForEach-Object { Join-Path $_ 'steamapps\workshop\content\1281930' } |
|
||||
Where-Object { Test-Path $_ } |
|
||||
ForEach-Object { (Resolve-Path $_).Path } |
|
||||
Sort-Object -Unique)
|
||||
if ($workshopDirs.Count -eq 0) { Write-Warning "No Steam Workshop folder for tModLoader found; only local mods will be exported." }
|
||||
|
||||
function Get-FolderVersion([string]$dir) {
|
||||
# Workshop items keep builds in folders named after the tModLoader version, e.g. 2024.3
|
||||
$v = $null
|
||||
if ([version]::TryParse((Split-Path $dir -Leaf), [ref]$v)) { return $v }
|
||||
return $null
|
||||
}
|
||||
|
||||
# --- Collect ------------------------------------------------------------------
|
||||
$modsOut = Join-Path $Out 'Mods'
|
||||
New-Item -ItemType Directory -Force -Path $modsOut | Out-Null
|
||||
$exported = @()
|
||||
$missing = @()
|
||||
|
||||
foreach ($mod in $enabled) {
|
||||
$candidates = @()
|
||||
foreach ($w in $workshopDirs) {
|
||||
$candidates += @(Get-ChildItem -Path $w -Recurse -File -Filter "$mod.tmod" -ErrorAction SilentlyContinue)
|
||||
}
|
||||
|
||||
$pick = $null
|
||||
if ($candidates.Count -gt 0) {
|
||||
$withVer = $candidates | ForEach-Object {
|
||||
[pscustomobject]@{ File = $_; Version = (Get-FolderVersion $_.DirectoryName) }
|
||||
}
|
||||
# Newest version folder that isn't newer than -MaxVersion
|
||||
$best = @($withVer |
|
||||
Where-Object { $_.Version -and (-not $MaxVersion -or $_.Version -le $MaxVersion) } |
|
||||
Sort-Object Version -Descending)
|
||||
if ($best.Count -gt 0) {
|
||||
$pick = $best[0].File
|
||||
} else {
|
||||
# Build stored at the item's top level (no version folder)
|
||||
$root = @($withVer | Where-Object { -not $_.Version } |
|
||||
Sort-Object { $_.File.LastWriteTime } -Descending)
|
||||
if ($root.Count -gt 0) { $pick = $root[0].File }
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $pick) {
|
||||
$local = Join-Path $TmlDir "Mods\$mod.tmod"
|
||||
if (Test-Path $local) { $pick = Get-Item $local }
|
||||
}
|
||||
|
||||
if ($pick) {
|
||||
Copy-Item $pick.FullName $modsOut -Force
|
||||
$exported += $mod
|
||||
Write-Host (" {0,-28} {1}" -f $mod, $pick.FullName)
|
||||
} else {
|
||||
$missing += $mod
|
||||
Write-Warning "$mod is enabled but no .tmod file was found; skipped."
|
||||
}
|
||||
}
|
||||
|
||||
# enabled.json listing only what we exported (a missing mod would stop the server loading)
|
||||
$json = ConvertTo-Json -InputObject @($exported)
|
||||
[IO.File]::WriteAllText((Join-Path $modsOut 'enabled.json'), $json, (New-Object Text.UTF8Encoding $false))
|
||||
|
||||
# Mod settings (server-side configs matter; client-side ones are harmless)
|
||||
$cfgSrc = Join-Path $TmlDir 'ModConfigs'
|
||||
if (Test-Path $cfgSrc) {
|
||||
$cfgOut = Join-Path $Out 'ModConfigs'
|
||||
New-Item -ItemType Directory -Force -Path $cfgOut | Out-Null
|
||||
Copy-Item (Join-Path $cfgSrc '*') $cfgOut -Recurse -Force
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Exported $($exported.Count) mod(s) to $Out"
|
||||
if ($missing.Count -gt 0) { Write-Warning "Missing: $($missing -join ', ')" }
|
||||
Reference in new issue
Block a user