#+TITLE: BeamMP (BeamNG Multiplayer Server) #+PROPERTY: header-args :exports both :results both :eval never-export #+OPTIONS: noweb:t #+SETUPFILE: theme/simple_dark.theme * One-time setup ** Create BeamMP Authentication Key [[https://docs.beammp.com/server/create-a-server/#2-obtaining-an-authentication-key][*** Follow the directions from the BeamMP manual to create your key]] ** Create DigitalOcean Team You should create a fresh Team dedicated for BeamMP, that way you can generate an admin level token just for BeamMP and it won't be able to affect any other Team's resources. *** [[https://cloud.digitalocean.com/account/team/create][Create a new Team]] Once the team is created, make sure to =Switch Team=, using the top right icon on the cloud console. *** [[https://cloud.digitalocean.com/account/api/tokens][Create an API token]] **** Choose the Scope: =Full Access= **** Choose Expiration: =No expire= (unless you want one) **** Copy the generated token. ** Setup doctl *** Install [[https://docs.digitalocean.com/reference/doctl/how-to/install/][doctl]] on your workstation *** Configure doctl #+begin_src bash doctl auth init --context beammp #+end_src When asked, paste the API token for your BeamMP Team. *** Create SSH key Create a key if you haven't already: #+begin_src bash ssh-keygen -t ed25519 #+end_src Add your key to the Team account: #+begin_src bash doctl --context beammp compute ssh-key import "${HOSTNAME}" --public-key-file ~/.ssh/id_ed25519.pub #+end_src ** Setup BeamMP cloud-init template Grab your BeamMP Authentication Key and paste it into a temporary bash variable (=GENERAL_AUTH_KEY=). Also set the default values for the rest of your config (these may be modified later on the server at =/opt/beammp/ServerConfig.toml=): #+begin_src bash export GENERAL_AUTH_KEY=xxxxxxxxx export GENERAL_NAME="My BeamMP server" export GENERAL_DESCRIPTION="BeamMP Default Description" export GENERAL_PORT=30814 export GENERAL_PRIVATE=true export GENERAL_TAGS="Freeroam,Friends" # Add comma separated tags export GENERAL_MAX_PLAYERS=8 #+end_src Create the cloud-init template for BeamMP (=~/.config/beammp/beammp-init.yaml=), which is going to hard code your Authentication Token into it and run on boot. /Keep this file safe/: #+begin_src bash mkdir -p ~/.config/beammp/ cat <<'EOF' > ~/.config/beammp/cloud-init-template.sh stderr(){ echo "$@" >/dev/stderr; } error(){ stderr "Error: $@"; } fault(){ test -n "$1" && error $1; stderr "Exiting."; exit 1; } check_var(){ local __missing=false local __vars="$@" for __var in ${__vars}; do if [[ -z "${!__var}" ]]; then error "${__var} variable is missing." __missing=true fi done if [[ ${__missing} == true ]]; then fault fi } check_var GENERAL_AUTH_KEY GENERAL_NAME GENERAL_DESCRIPTION GENERAL_PORT \ GENERAL_PRIVATE GENERAL_TAGS GENERAL_MAX_PLAYERS cat < ~/.config/beammp/beammp-init.yaml #cloud-config write_files: - path: /usr/local/bin/beammp-setup.sh permissions: '0755' content: | #!/bin/bash exec > >(tee -a /var/log/beammp-setup.log) 2>&1 set -euxo pipefail echo "Starting BeamMP setup..." apt update DEBIAN_FRONTEND=noninteractive apt upgrade -yq \ -o Dpkg::Options::="--force-confdef" \ -o Dpkg::Options::="--force-confold" apt install -y curl liblua5.3-0 jq tmux unzip echo "Downloading BeamMP server..." curl -L "https://github.com/BeamMP/BeamMP-Server/releases/download/v3.4.1/BeamMP-Server.debian.12.x86_64" \ -o /usr/local/bin/beammp chmod +x /usr/local/bin/beammp mkdir -p /opt/beammp echo 'BeamMP Server is installed. Use: /usr/local/bin/beammp' > /opt/beammp/README.txt echo "Running BeamMP server..." cd /opt/beammp /usr/local/bin/beammp || true sed -i "s|^AuthKey = \".*\"|AuthKey = \"${GENERAL_AUTH_KEY}\"|" /opt/beammp/ServerConfig.toml sed -i "s|^MaxPlayers = \".*\"|MaxPlayers = \"${GENERAL_MAX_PLAYERS}\"|" /opt/beammp/ServerConfig.toml sed -i "s|^Description = \".*\"|Description = \"${GENERAL_DESCRIPTION}\"|" /opt/beammp/ServerConfig.toml sed -i "s|^Tags = \".*\"|Tags = \"${GENERAL_TAGS}\"|" /opt/beammp/ServerConfig.toml sed -i "s|^Port = \".*\"|Port = \"${GENERAL_PORT}\"|" /opt/beammp/ServerConfig.toml sed -i "s|^Private = \".*\"|Private = \"${GENERAL_PRIVATE}\"|" /opt/beammp/ServerConfig.toml sed -i "s|^Name = \".*\"|Name = \"${GENERAL_NAME}\"|" /opt/beammp/ServerConfig.toml echo echo "Authentication Key updated in /opt/beammp/ServerConfig.toml" useradd -m -d /opt/beammp -s /bin/bash beammp chown -R beammp:beammp /opt/beammp install -d -m 700 -o beammp -g beammp /opt/beammp/.ssh install -m 600 -o beammp -g beammp /root/.ssh/authorized_keys /opt/beammp/.ssh/authorized_keys runcmd: - /usr/local/bin/beammp-setup.sh FOF EOF chmod +x ~/.config/beammp/cloud-init-template.sh ~/.config/beammp/cloud-init-template.sh #+end_src ** Setup Droplet creation script #+begin_src bash mkdir -p ~/.config/beammp/ cat <<'EOF' > ~/.config/beammp/create-droplet.sh set -euo pipefail DROPLET_NAME="${DROPLET_NAME:-beammp}" REGION="${REGION:-nyc3}" SIZE="${SIZE:-s-1vcpu-1gb}" IMAGE="${IMAGE:-debian-12-x64}" SSH_KEYS=${SSH_KEYS:-$(doctl --context ${DROPLET_NAME} compute ssh-key list --format ID --no-header | paste -sd ",")} USER_DATA_FILE="${USER_DATA_FILE:-${HOME}/.config/beammp/${DROPLET_NAME}-init.yaml}" echo "🚀 Creating Droplet '$DROPLET_NAME'..." # Fail fast if droplet already exists if doctl --context "${DROPLET_NAME}" compute droplet list --no-header --format Name | grep -qx "$DROPLET_NAME"; then echo "❌ Droplet '$DROPLET_NAME' already exists." exit 1 fi DROPLET_ID=$(doctl --context ${DROPLET_NAME} compute droplet create "$DROPLET_NAME" \ --region "$REGION" \ --size "$SIZE" \ --image "$IMAGE" \ --ssh-keys "$SSH_KEYS" \ --tag-names "$DROPLET_NAME" \ --user-data-file "$USER_DATA_FILE" \ --format ID --no-header) echo "🆔 Droplet created: $DROPLET_ID" echo "🌐 Waiting for droplet to get IP..." while true; do IP=$(doctl --context ${DROPLET_NAME} compute droplet get "$DROPLET_ID" --format PublicIPv4 --no-header) if [[ -n "$IP" && "$IP" != "0.0.0.0" ]]; then echo "✅ IP: $IP" break fi sleep 5 done echo "🔐 Waiting for SSH..." until ssh -o StrictHostKeyChecking=no -o ConnectTimeout=3 "root@$IP" 'echo ok' &>/dev/null; do sleep 5 done echo "📦 Waiting for cloud-init on $IP..." ssh root@$IP 'bash -c " set -euo pipefail cloud-init status --wait || tail /var/log/cloud-init-output.log cloud-init status --long | grep -q '\''status: done'\'' && echo -n OK || echo FAIL "' echo "👍️ To connect, run: ssh beammp@$IP" EOF chmod +x ~/.config/beammp/create-droplet.sh #+end_src * Create a new server You can repeat the following steps whenever you want to create a new BeamMP server. ** Create Droplet #+begin_src bash ~/.config/beammp/create-droplet.sh #+end_src Once finished, it will print instructions to connect via SSH, directly by IP address: #+begin_src bash ## Example output from creation script: 👍️ To connect, run: ssh root@x.x.x.x #+end_src ** Run BeamMP server *** Log into the server via SSH as shown earlier. *** Run the server in a tmux session: #+begin_src bash tmux new-session -d -s beammp -c /opt/beammp 'beammp' #+end_src The server runs in the foreground and provides a REPL for controlling it. Connect to the tmux session to interact with the running server: #+begin_src bash tmux attach-session -t beammp #+end_src To disconnect, press =Ctrl+b= then press =d=. ** Install Maps [[https://www.beamng.com/resources/categories/terrains-levels-maps.9/][Download maps from Mods Page]] and put them in the =/opt/beammp/Resources/Client= directory. These should be single .zip files - don't extract them. Find the canonical map name by peeking inside the .zip file: #+begin_src bash ## Example unzip -l motorsports_playground.zip | head #+end_src This shows a folder structure inside the .zip something like: #+begin_example Length Date Time Name --------- ---------- ----- ---- 0 2024-12-15 20:52 levels/ 0 2024-12-15 20:52 levels/motorsports_playground #+end_example In this example =motorsports_playground= is the canonical name of the map. Edit =/opt/beammp/ServerConfig.toml=, and set the map name like this, (specifying the map name in the middle of the path): #+begin_example Map = "/levels/motorsports_playground/info.json" #+end_example Save the file and restart beammp: #+begin_src bash killall beammp tmux new-session -d -s beammp -c /opt/beammp 'beammp' #+end_src ** Power off droplet and take a snapshot: #+begin_src bash DROPLET_ID=$(doctl --context beammp compute droplet list --no-header --format Name,ID | awk '$1=="beammp"{print $2}') SNAPSHOT_NAME="beammp" DOCTL_CONTEXT=beammp doctl_wait_for_action() { local CONTEXT=${DOCTL_CONTEXT:-default} local INTERVAL=10 echo "▶️ Running: doctl --context $CONTEXT $*" ACTION_ID=$(doctl --context "$CONTEXT" "$@" --format ID --no-header) if [[ -z "$ACTION_ID" ]]; then echo "❌ Failed to extract action ID" return 1 fi echo "⏳ Waiting for action $ACTION_ID to complete..." while true; do STATUS=$(doctl --context "$CONTEXT" compute action get "$ACTION_ID" --format Status --no-header) echo "🕐 Status: $STATUS" [[ "$STATUS" == "completed" ]] && break sleep "$INTERVAL" done echo "✅ Action $ACTION_ID completed." } echo "🔌 Powering off droplet..." doctl_wait_for_action compute droplet-action power-off "$DROPLET_ID" echo "📸 Taking snapshot: $SNAPSHOT_NAME" doctl_wait_for_action compute droplet-action snapshot "$DROPLET_ID" --snapshot-name "${SNAPSHOT_NAME}" echo "✅ Snapshot completed: $SNAPSHOT_NAME" #+end_src ** Delete droplet #+begin_src bash doctl --context beammp compute droplet delete beammp #+end_src ** Restore droplet from snapshot #+begin_src bash IMAGE=$(doctl --context beammp compute image list-user --public=false --format ID --no-header) doctl --context beammp compute droplet create beammp \ --region nyc3 \ --size s-1vcpu-1gb \ --image ${IMAGE} \ --ssh-keys "$(doctl --context beammp compute ssh-key list --format ID --no-header | paste -sd ',' -)" \ --tag-names beammp \ --wait #+end_src