diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fe7d01..b9e388a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 0.0.9 - 2026-09-28 + +- New `export` command: `terractl export [file.tar.gz] [--with-server]` archives an instance's `instance.conf`, `serverconfig.txt` and `data/` (worlds, mods, mod configs). If the server is running under systemd, it is asked to save first. Archives are created readable only by their owner, since `serverconfig.txt` can contain the server password. `--with-server` also includes the unpacked server software (and tModLoader's .NET), for hosts without internet access. +- New `import` command: `terractl import ` creates an instance from an export, a `delete` backup, or an archive of an old `/opt/terraria` instance. Archives are checked before extracting (no paths outside the instance folder, no links or special files). Paths in `serverconfig.txt` are updated, a port already used by another instance is replaced with a free one, the server version the world was exported with is pinned, and ownership is set to the `terraria` user. +- One server per instance: `run` holds a lock (`/.lock`) for the server's whole lifetime, released automatically when it exits or crashes. A second `run`, or starting the service while a copy started by hand is running, is refused. +- `prepare` and `reinstall --clean` refuse to run while the instance's server is running, so `server/` and .NET are never replaced or deleted under it. +- `delete` refuses to continue if the server is still running outside systemd. +- `list` shows servers started by hand as `running (outside systemd)`. +- `stop` typed by hand now explains to use `systemctl stop` instead of silently doing nothing. +- Fixed relative paths given on the command line (e.g. `terractl mods add tmod-export`) being looked up from `/` instead of the current directory. +- Bash completion for `export` and `import`. + ## 0.0.8 - 2026-09-28 - Bash completion for `terractl`: commands, instance names, `new` types, console commands for `cmd`, `reinstall`/`delete` options, and `mods` actions and mod names. Installed to `share/bash-completion/completions/terractl` by both `make install` and the package. diff --git a/README.md b/README.md index 842b029..e8ce69b 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ sudo terractl attach calamity - **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. - **Tab completion** for commands, instance names, mods and console commands. +- **Export and import.** Pack an instance into one `.tar.gz` for backups or moving to another machine, and import it anywhere, with paths, ports and ownership adjusted. - **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. diff --git a/VERSION b/VERSION index d169b2f..c5d54ec 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.8 +0.0.9 diff --git a/bin/terractl b/bin/terractl index 6c93854..355cfd8 100755 --- a/bin/terractl +++ b/bin/terractl @@ -12,10 +12,16 @@ # 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). +# Work from / so nothing depends on the caller's directory (which the 'terraria' +# user may not be allowed into, e.g. /root). Paths given on the command line are +# resolved against where terractl was called from, via abspath(). +CALLER_PWD=$PWD cd / +abspath() { + if [[ $1 == /* ]]; then printf '%s\n' "$1"; else printf '%s\n' "$CALLER_PWD/$1"; fi +} + # Local overrides (see the comments in that file) # shellcheck disable=SC1091 if [[ -r /etc/default/terractl ]]; then . /etc/default/terractl; fi @@ -47,12 +53,16 @@ Admin commands: mods enable|disable ... change which installed mods load mods remove ... uninstall mods delete [-y] [--no-backup] stop, disable and remove an instance + export [file.tar.gz] [--with-server] + archive an instance (configs, worlds, mods) + import create instance from an archive (worlds/configs archived to $BASE_DIR/.deleted/) Used by terraria@.service: prepare create dirs/configs, unpack server if needed run exec the server - stop graceful shutdown via console "exit" + stop graceful shutdown via console "exit" (service only; + to stop a server use: systemctl stop terraria@) EOF exit "${1:-1}" } @@ -215,9 +225,40 @@ install_server() { log "install complete" } +# One server (or prepare) per instance at a time. The lock is held on file +# descriptor 9. 'run' passes it on to the server process, so it's held for the +# server's whole lifetime and released by the kernel when the server exits, +# however it exits; it can never go stale. +take_lock() { + local name=$1 dir state + dir=$(inst_dir "$name") + exec 9>"$dir/.lock" + if ! flock -n 9; then + state=$(systemctl show -p ActiveState --value "terraria@$name" 2>/dev/null || true) + case $state in + active|activating|deactivating|reloading) + die "terraria@$name is $state; stop it first: systemctl stop terraria@$name" ;; + *) + die "instance '$name' is already running outside systemd (e.g. 'terractl run' in another terminal); stop that first" ;; + esac + fi +} + +# True while a server (or prepare) holds the instance lock. Only tests the lock: +# never creates or changes the file, so it's safe for root and for members of +# the 'terraria' group. If the lock can't be read, assumes nothing is running. +instance_running() { + local lock rc=0 + lock="$(inst_dir "$1")/.lock" + [[ -r $lock ]] || return 1 + flock -n -E 75 "$lock" true 2>/dev/null || rc=$? + [[ $rc -eq 75 ]] +} + cmd_prepare() { local name=$1 dir; dir=$(inst_dir "$name") mkdir -p "$dir/data/Worlds" + take_lock "$name" # never re-unpack server/ under a running server [[ -f $dir/instance.conf ]] || write_instance_conf "$name" vanilla load_conf "$name" if [[ $TYPE == tmodloader ]]; then mkdir -p "$dir/data/Mods"; fi @@ -227,6 +268,8 @@ cmd_prepare() { cmd_run() { local name=$1 dir; dir=$(inst_dir "$name") + [[ -f $dir/instance.conf ]] || die "no instance '$name'" + take_lock "$name" # held by the server process until it exits 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 @@ -250,6 +293,11 @@ cmd_run() { cmd_stop() { local name=$1 pid=${2:-} fifo="$RUN_DIR/$1.stdin" i + # systemd sets INVOCATION_ID for every process it runs for a service + if [[ -z ${INVOCATION_ID:-} ]]; then + die "'stop' is only for terraria@.service. To stop the server: systemctl stop terraria@$name" + fi + if [[ -n $pid && ! $pid =~ ^[0-9]+$ ]]; then die "invalid process ID '$pid'"; fi if [[ -z $pid ]] || ! kill -0 "$pid" 2>/dev/null; then exit 0; fi if [[ -p $fifo ]]; then echo exit > "$fifo" @@ -332,6 +380,9 @@ cmd_delete() { systemctl stop "terraria@$name.socket" 2>/dev/null || true systemctl reset-failed "terraria@$name.service" "terraria@$name.socket" 2>/dev/null || true fi + if instance_running "$name"; then + die "'$name' is still running outside systemd (e.g. 'terractl run' in another terminal); stop it, then run delete again" + fi if [[ $backup -eq 1 ]]; then mkdir -p "$BASE_DIR/.deleted" @@ -347,6 +398,179 @@ cmd_delete() { echo "deleted instance '$name'" } +# ------------------------------------------------------- export/import ---- +# Archive layout: terractl-export.txt (info about the export; optional) +# /... (instance.conf, serverconfig.txt, data/, [server/]) +# Backups made by 'delete' have the same layout without the info file, so they +# can be imported too. +EXPORT_INFO=terractl-export.txt + +# Ask a server running under systemd to save, then wait until its world files +# have been written and stopped changing. Best effort: carries on after 60s. +save_and_wait() { + local name=$1 dir marker newest last="" stable=0 i + dir=$(inst_dir "$name") + marker=$(mktemp) + printf 'save\n' > "$RUN_DIR/$name.stdin" + log "'$name' is running: asked it to save, waiting for the world files..." + for ((i = 0; i < 60; i++)); do + sleep 1 + newest=$(find "$dir/data/Worlds" -maxdepth 1 -type f -newer "$marker" -printf '%T@\n' 2>/dev/null | + sort -n | tail -n 1) || true + [[ -n $newest ]] || continue + if [[ $newest == "$last" ]]; then + stable=$((stable + 1)) + if [[ $stable -ge 3 ]]; then rm -f "$marker"; log "save finished"; return 0; fi + else + stable=0; last=$newest + fi + done + rm -f "$marker" + log "warning: no completed save seen within 60s; exporting the last saved state" +} + +cmd_export() { + local name=$1; shift + local out="" with_server=0 arg dir installed_zip="" ver rc=0 + for arg in "$@"; do + case $arg in + --with-server) with_server=1 ;; + -*) die "unknown option '$arg'" ;; + *) [[ -z $out ]] || die "more than one output file given"; out=$arg ;; + esac + done + dir=$(inst_dir "$name") + [[ -f $dir/instance.conf ]] || die "no instance '$name'" + load_conf "$name" + + [[ -n $out ]] || out="$name-$(date +%Y%m%d-%H%M%S).tar.gz" + out=$(abspath "$out") + [[ $out == *.tar.gz || $out == *.tgz ]] || out="$out.tar.gz" + [[ ! -e $out ]] || die "$out already exists" + [[ -d $(dirname "$out") ]] || die "folder $(dirname "$out") does not exist" + + if instance_running "$name"; then + if [[ -p $RUN_DIR/$name.stdin ]]; then + save_and_wait "$name" + else + die "'$name' is running outside systemd, so terractl can't ask it to save; stop it first (type 'exit' in its console)" + fi + fi + + if [[ -r $dir/server/.installed ]]; then read -r _ installed_zip < "$dir/server/.installed" || true; fi + ver=$TERRACTL_VERSION; if [[ $ver == *@* ]]; then ver=development; fi + EXTRACT_TMP=$(mktemp -d) # removed by cleanup() on exit + { + echo "# Created by 'terractl export'. Import with: terractl import " + echo "format=1" + echo "terractl=$ver" + echo "instance=$name" + echo "type=$TYPE" + echo "server_zip=$installed_zip" + echo "includes_server=$(if [[ $with_server -eq 1 ]]; then echo yes; else echo no; fi)" + echo "exported=$(date -Iseconds)" + echo "host=$(hostname 2>/dev/null || echo unknown)" + } > "$EXTRACT_TMP/$EXPORT_INFO" + + local -a excludes=(--exclude="$name/.lock" --exclude="$name/.extract.*" + --exclude="$name/data/Mods/.enabled.json.tmp") + if [[ $with_server -eq 0 ]]; then excludes+=(--exclude="$name/server"); fi + + # serverconfig.txt can hold the server password, so the archive is private (0600) + ( + umask 077 + tar -czf "$out" --owner=0 --group=0 --numeric-owner "${excludes[@]}" \ + -C "$EXTRACT_TMP" "$EXPORT_INFO" -C "$BASE_DIR" "$name" + ) || rc=$? + if [[ $rc -ge 2 ]]; then rm -f "$out"; die "export failed (tar exit status $rc)"; fi + if [[ $rc -eq 1 ]]; then + log "warning: some files changed while being archived (an autosave?); consider exporting again" + fi + echo "exported '$name' to $out ($(du -h "$out" | cut -f1), readable only by $(id -un))" + if [[ $with_server -eq 0 ]]; then + echo "server/ not included; it's unpacked again from ${installed_zip:-the server zip} on first start after import" + fi +} + +cmd_import() { + local name=$1; shift + [[ $# -eq 1 ]] || die "usage: terractl import " + local file dir listing types m top="" meta_zip="" meta_from="" port newport p f clash=0 zip="" + file=$(abspath "$1") + [[ -f $file && -r $file ]] || die "cannot read $file" + dir=$(inst_dir "$name") + [[ ! -e $dir ]] || die "instance '$name' already exists; choose another name, or delete it first" + + # Check the archive before extracting anything + listing=$(tar -tzf "$file" 2>/dev/null) || die "$file is not a readable .tar.gz archive" + types=$(tar -tvzf "$file" 2>/dev/null | cut -c1 | sort -u | tr -d '\n') + [[ $types =~ ^[-d]+$ ]] || die "archive contains links or special files; refusing to import it" + local -A tops=() + while IFS= read -r m; do + case $m in + /*|..|../*|*/../*|*/..) die "archive contains an unsafe path '$m'; refusing to import it" ;; + esac + m=${m#./} + if [[ -z $m || $m == . || $m == "$EXPORT_INFO" ]]; then continue; fi + tops[${m%%/*}]=1 + done <<< "$listing" + if [[ ${#tops[@]} -ne 1 ]]; then + local found="nothing" + if [[ ${#tops[@]} -gt 0 ]]; then found="${!tops[*]}"; fi + die "expected one instance folder in the archive (as 'terractl export' creates); found: $found" + fi + top=${!tops[*]} + + mkdir -p "$BASE_DIR" + EXTRACT_TMP=$(mktemp -d "$BASE_DIR/.import.XXXXXX") # removed by cleanup() on exit + ( umask 027; tar -xzf "$file" -C "$EXTRACT_TMP" --no-same-owner --no-same-permissions ) || + die "extracting $file failed" + [[ -f $EXTRACT_TMP/$top/instance.conf ]] || die "no instance.conf in '$top/'; this isn't a terractl instance archive" + if [[ -f $EXTRACT_TMP/$EXPORT_INFO ]]; then + meta_zip=$(sed -n 's/^server_zip=//p' "$EXTRACT_TMP/$EXPORT_INFO" | tail -n 1) + meta_from=$(sed -n 's/^instance=//p' "$EXTRACT_TMP/$EXPORT_INFO" | tail -n 1) + fi + mv "$EXTRACT_TMP/$top" "$dir" + rm -rf "$EXTRACT_TMP"; EXTRACT_TMP="" + + # serverconfig.txt holds absolute paths from where it came from; keep only + # the part after /data/ (this also converts old /opt/terraria instances) + if [[ -f $dir/serverconfig.txt ]]; then + sed -i -E "s#^(world|worldpath|banlist)=.*/data/#\1=$dir/data/#" "$dir/serverconfig.txt" + port=$(sed -n 's/^port=\([0-9][0-9]*\).*/\1/p' "$dir/serverconfig.txt" | tail -n 1) + if [[ -n $port ]]; then + for f in "$BASE_DIR"/*/serverconfig.txt; do + [[ -f $f && $f != "$dir/serverconfig.txt" ]] || continue + p=$(sed -n 's/^port=\([0-9][0-9]*\).*/\1/p' "$f" | tail -n 1) + if [[ $p == "$port" ]]; then clash=1; fi + done + if [[ $clash -eq 1 ]]; then + newport=$(next_free_port) + sed -i "s/^port=.*/port=$newport/" "$dir/serverconfig.txt" + log "port $port is already used by another instance; '$name' now uses port $newport" + fi + fi + fi + + # Keep the server version the world was exported with, unless one is pinned already + load_conf "$name" + if [[ -z $ZIP && $meta_zip =~ ^[A-Za-z0-9._+-]+\.zip$ ]]; then + sed -i "s/^ZIP=.*/ZIP=$meta_zip/" "$dir/instance.conf" + ZIP=$meta_zip + log "pinned ZIP=$meta_zip in instance.conf (the version it was exported with)" + fi + if [[ ! -f $dir/server/.installed ]]; then + zip=$(pick_zip 2>/dev/null) || true + if [[ -z $zip || ! -r $zip ]]; then + log "warning: before starting, put ${ZIP:-a $TYPE server zip} in $DIST_DIR" + fi + fi + + if [[ $EUID -eq 0 ]]; then chown -R "$SVC_USER:$SVC_USER" "$dir"; fi + echo "imported${meta_from:+ '$meta_from' as} '$name' into $dir" + echo "Review $dir/serverconfig.txt, then: systemctl enable --now terraria@$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). @@ -404,6 +628,7 @@ mods_add() { local src f n found local -a files=() cfgs=() for src in "$@"; do + src=$(abspath "$src") if [[ -f $src ]]; then [[ $src == *.tmod ]] || die "$src is not a .tmod file" files+=("$src") @@ -540,6 +765,7 @@ cmd_list() { 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) + if [[ $state != active ]] && instance_running "$n"; then state="running (outside systemd)"; fi printf '%-20s %-11s %-6s %s\n' "$n" "$TYPE" "${port:--}" "$state" ) 2>/dev/null || printf '%-20s %s\n' "$n" "(cannot read config - try sudo)" done @@ -554,6 +780,9 @@ cmd_reinstall() { *) die "unknown option '$1'" ;; esac [[ -f $dir/instance.conf ]] || die "no instance '$name'" + if [[ $clean -eq 1 ]] && instance_running "$name"; then + die "'$name' is running; --clean would delete .NET under it. Stop it first: systemctl stop terraria@$name" + fi rm -f "$dir/server/.installed" if [[ $clean -eq 1 ]]; then rm -rf "$dir/server/dotnet" "$dir/server/tModLoader-Logs" @@ -598,5 +827,7 @@ case $command in new) cmd_new "$@" ;; reinstall) cmd_reinstall "$@" ;; delete) cmd_delete "$@" ;; + export) cmd_export "$@" ;; + import) cmd_import "$@" ;; *) usage ;; esac diff --git a/completions/bash/terractl b/completions/bash/terractl index c017fdb..d7e5392 100644 --- a/completions/bash/terractl +++ b/completions/bash/terractl @@ -46,6 +46,20 @@ _terractl_mods() { fi } +# File names, optionally only those ending in . (folders always offered) +_terractl_files() { + if declare -F _filedir >/dev/null 2>&1; then + _filedir "$@" + else + compopt -o filenames 2>/dev/null + if [[ -n ${1:-} ]]; then + mapfile -t COMPREPLY < <(compgen -d -- "$cur"; compgen -f -X "!*.$1" -- "$cur") + else + mapfile -t COMPREPLY < <(compgen -f -- "$cur") + fi + fi +} + # Set COMPREPLY to the words matching what's typed so far _terractl_reply() { mapfile -t COMPREPLY < <(compgen -W "$1" -- "$cur") @@ -63,7 +77,7 @@ _terractl() { cword=$COMP_CWORD fi - local commands="new list attach cmd mods reinstall delete prepare run --help --version" + local commands="new list attach cmd mods reinstall delete export import prepare run --help --version" local console="save say playing kick ban motd password time dawn noon dusk midnight settle seed version maxplayers port clear help" if (( cword == 1 )); then @@ -79,6 +93,10 @@ _terractl() { # new [vanilla|tmodloader] [port]: the name is new, so nothing to offer if (( cword == 3 )); then _terractl_reply "vanilla tmodloader"; fi return ;; + import) + # import : the name is new, so nothing to offer + if (( cword == 3 )); then _terractl_files tar.gz; fi + return ;; esac if (( cword == 2 )); then @@ -94,6 +112,8 @@ _terractl() { if (( cword == 3 )); then _terractl_reply "--clean"; fi ;; delete) _terractl_reply "-y --yes --no-backup" ;; + export) + if [[ $cur == -* ]]; then _terractl_reply "--with-server"; else _terractl_files tar.gz; fi ;; mods) if (( cword == 3 )); then _terractl_reply "list add enable disable remove" @@ -102,12 +122,7 @@ _terractl() { case ${words[3]} in add) # .tmod files and folders (e.g. the Windows export folder) - if declare -F _filedir >/dev/null 2>&1; then - _filedir tmod - else - compopt -o filenames 2>/dev/null - mapfile -t COMPREPLY < <(compgen -d -- "$cur"; compgen -f -X '!*.tmod' -- "$cur") - fi ;; + _terractl_files tmod ;; enable|remove) _terractl_reply "$(_terractl_mods "$instance" installed)" ;; disable) diff --git a/docs/guide.md b/docs/guide.md index 79f08e3..2899a35 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -17,12 +17,13 @@ Run any number of vanilla Terraria and tModLoader dedicated servers on one Linux 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. [Tab completion](#tab-completion) -19. [Command reference](#command-reference) +14. [Exporting and importing](#exporting-and-importing) +15. [Backups](#backups) +16. [Permissions](#permissions) +17. [Running by hand for debugging](#running-by-hand-for-debugging) +18. [Troubleshooting](#troubleshooting) +19. [Tab completion](#tab-completion) +20. [Command reference](#command-reference) --- @@ -95,7 +96,7 @@ The server can write only inside `instances/`. It reads `dist/` but can never ch 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 +sudo apt install ./terraria-server-manager_0.0.9_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. @@ -137,10 +138,10 @@ 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: +Versions before 0.0.6 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 +sudo apt install ./terraria-server-manager_0.0.9_all.deb ``` Things to know: @@ -203,7 +204,7 @@ sudo rm /etc/systemd/system/terraria@.service /etc/systemd/system/terraria@.sock 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 +sudo apt install ./terraria-server-manager_0.0.9_all.deb # 4. Move instances and zips for d in /opt/terraria/*/; do @@ -222,6 +223,8 @@ sudo systemctl reenable terraria@calamity # repeat for each instance from sudo systemctl start terraria@calamity ``` +Alternatively, you can move instances one at a time with `import`, which updates the paths in `serverconfig.txt` and sets ownership for you: `sudo tar -czf /tmp/calamity.tar.gz -C /opt/terraria calamity`, then `sudo terractl import calamity /tmp/calamity.tar.gz`. + 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`. @@ -533,11 +536,10 @@ 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/-.tar.gz`. `server/` is left out of the archive because it can be recreated from the zip. -To restore an instance: +To restore an instance, import its backup. You can give it back its old name, or a new one: ```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 terractl import calamity /var/lib/terraria/instances/.deleted/calamity-20260928-134422.tar.gz sudo systemctl enable --now terraria@calamity # server/ is unpacked again on start ``` @@ -545,20 +547,67 @@ sudo systemctl enable --now terraria@calamity # server/ is unpacked ag --- -## Backups +## Exporting and importing -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: +`export` packs an instance into a single `.tar.gz` file, and `import` turns such a file back into an instance. Use them for backups, for moving a server to another machine, or for making a copy to experiment on. ```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 +sudo terractl export calamity # ./calamity--