arma-reforger-server-launch
Launch an Arma Reforger dedicated server on the user's machine or on a remote one over SSH and prove it is up - ask where it should run, check the target for the server and Node.js, install what is mi
SKILL.mdskills/arma-reforger-server-launch/SKILL.md
GitHubLaunching an Arma Reforger dedicated server
This skill takes a server from "I have a machine" to "players can join, and here is the evidence". It runs on the machine where the server will live — the user's own or a remote one over SSH — and everything it checks is checked there, because the log, the sockets and the local RCON only exist on that machine.
The keys, ranges and error strings live in arma-reforger-server-config and the config guide; this skill covers the target machine, the prerequisites, the interview, the folder layout, the command line, the launch, and the checks that prove it worked.
0. What you are producing
- A target — either this machine or an SSH connection to the remote one — with the server and
Node.js present (installed in §2 if missing) and the repository's
tools/copied onto it (§3). - A folder next to the server executable (default
<serverDir>/<ServerName>/) that holdsconfig.json, a start script for the platform, and — if the user chose it — the profile. - A validated
server.jsonwhose values are the user's answers, not your guesses. - A running server, evidenced by the log line
Server registered with address:and by the operating-system socket table on the target. - Instructions: the join code / address, the ports to open, and the two things that make a server look broken (NAT hairpin, and running the server inside a sandbox).
Do not write anything to the config until the interview in §5 is answered. The user's rule is that these questions are asked unless they already answered them — so ask once, in one message, with the recommended option marked, and skip what the user has already stated.
1. Ask first: where does the server run?
This is the first question, before any other. Two answers:
- On this machine — the machine the agent is working on. The target is a local shell.
- On a remote machine over SSH — a VPS or another host. The target is an SSH session, and you need its connection details.
Ask for the remote case:
Remote server (SSH):
host / IP
user
port [22]
key [the default key from ssh-agent or ~/.ssh/id_*]
Then test the connection non-interactively before doing anything else:
# Linux target
ssh -o BatchMode=yes -o ConnectTimeout=8 -p <port> <user>@<host> 'uname -s; uname -m; id -un'
# Windows target (cmd is the default shell there)
ssh -o BatchMode=yes -o ConnectTimeout=8 -p <port> <user>@<host> 'cmd /c ver'
BatchMode=yesfails instead of prompting, which is what you want: an agent cannot answer an SSH password prompt, and a password must not be pasted into the conversation or used withsshpass. If it fails withPermission denied (publickey), ask the user to authorise your key (ssh-copy-id -p <port> <user>@<host>) or to run the commands themselves; do not try to work around it.- Record the working form once and use it for every command below. Write it as
TARGET="ssh -o BatchMode=yes -p <port> <user>@<host>"in your notes (not in the config), and remember that quoting through SSH is the usual failure: prefer copying a.sh/.ps1file to the target and running that over trying to nest quotes. - Check the target's OS and architecture: the dedicated server is x86_64 only, and ARM64 is
unsupported (
uname -mmust bex86_64). Ifunameis not found, the login shell is cmd or PowerShell — a Windows target, whose commands are PowerShell and where the batch-file caveats in §8 apply.
Two environment facts to settle now, because they change the rest of the procedure:
- Not a sandbox. On the local machine, launch the server from an unrestricted shell (§8). A remote SSH session is not a sandbox — but the remote host itself can be one: a container, a host without a CA store, or Steam Linux Runtime on Linux all break the backend (see §9). This is not about your tooling; it is about where the process runs.
- Who owns the ports. On a home machine, ports are opened on the router (§11.2). On a VPS, the host usually has a public IP, so there is no router rule — but the provider's firewall and the OS firewall still have to allow the UDP ports (§11.3). Ask which case it is when you get to §11.
2. What must already be on the target — check, then install
2.1 The check
Run this on the target (wrap it in the SSH form from §1 for a remote one). It answers four things: is Node.js there and new enough, is SteamCMD available, is the server installed, and can we install packages.
# Linux target
SERVER_DIR=/home/steam/reforger/server # adjust to the target's install path
node --version 2>/dev/null || echo "node: MISSING"
command -v steamcmd || ls /usr/games/steamcmd 2>/dev/null || echo "steamcmd: MISSING"
sudo -n true 2>/dev/null && echo "sudo: passwordless" || echo "sudo: needs a password"
ls -d "$SERVER_DIR" 2>/dev/null || echo "server dir: MISSING"
test -x "$SERVER_DIR/ArmaReforgerServer" && echo "server binary: ok"
test -f "$SERVER_DIR/addons/data/ArmaReforger.gproj" && echo "game addons: ok"
df -h "$SERVER_DIR" 2>/dev/null | tail -1 # the install needs several GB
# Windows target
node --version
Get-Command steamcmd.exe -ErrorAction SilentlyContinue
Test-Path "$serverDir\ArmaReforgerServer.exe"
Test-Path "$serverDir\addons\data\ArmaReforger.gproj"
Interpretation:
- Node.js is required by the tools, not by the server — the checks in §3 are plain Node scripts and need Node 18 or newer. If it is missing or older, install it (§2.2).
- The server must exist in a directory whose name ends with the install itself: the binary
(
ArmaReforgerServer.exeon Windows,ArmaReforgerServeron Linux) next toaddons/, withaddons/data/ArmaReforger.gprojpresent. An emptyaddons/is a broken install. - sudo decides who can run the install commands: passwordless sudo means you can run them; a password prompt means the user has to.
2.2 Install Node.js
Prefer the distribution's package, but check the version afterwards — the tools need 18+:
# Debian / Ubuntu (NodeSource gets a current LTS; the distro package can be too old)
sudo apt-get update
sudo apt-get install -y ca-certificates curl
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y nodejs
# Fedora / RHEL
sudo dnf install -y nodejs
# Arch
sudo pacman -S --needed nodejs npm
# Windows
winget install --id OpenJS.NodeJS.LTS -e
Then re-run node --version. If sudo needs a password, give the user the exact commands and ask
them to run them; never ask for the password.
2.3 Install the dedicated server
Two routes; pick by the target:
A headless remote (Linux VPS) — SteamCMD, anonymous login. The depot is public:
sudo apt-get install -y steamcmd # Debian/Ubuntu: this also brings the 32-bit libs # or, without a package: mkdir -p ~/steamcmd && cd ~/steamcmd && # curl -sqL "https://steamcdn-a.akamaihd.net/client/installer/steamcmd_linux.tar.gz" | tar zxvf - mkdir -p "$SERVER_DIR" steamcmd +force_install_dir "$SERVER_DIR" +login anonymous +app_update 1874900 validate +quitApp id 1874900 is stable, 1890870 is experimental.
+force_install_dirmust come before+app_update, or SteamCMD updates its own directory instead of the server's. If you used the tarball rather than the package, the command is./steamcmd.shin~/steamcmd. The Linux server is a native x86_64 binary — no Wine, no Proton, and it does not work under Steam Linux Runtime (T165196).A Windows machine the user sits at — the Steam client. Library → Tools → “Arma Reforger Server” (app 1874900), install it, then use the path Steam shows. Or, if SteamCMD is at hand, the same
+app_updateline as above.One or the other, never both into the same directory. If the user installs it in Steam, do not run SteamCMD over the top; ask for the install path instead.
After the install, verify §2.1 again, and note the exact binary name: Linux installers usually
produce ArmaReforgerServer, but check ls "$SERVER_DIR" | grep -i arma.
2.4 If neither route works
Say what is missing and stop before writing the config: a server directory without addons/ cannot
start, and no config fixes that. Ask the user to install it (Steam client or SteamCMD) and give you
the path. The rest of the skill applies unchanged once the path exists.
3. Carry the tools to the target
Everything you check from here on runs on the target, and the scripts that do it are in this
repository. Copy the tree, keeping the layout: validate-server-config.mjs resolves
../schemas/server.schema.json and ../data/*.json, so tools/ alone is not enough.
# Linux target (local or over SSH), from the repository root
tar czf - tools schemas data package.json \
| ssh -o BatchMode=yes -p <port> <user>@<host> \
'mkdir -p ~/arma-reforger-tools && tar xzf - -C ~/arma-reforger-tools'
# Windows target over SSH, from the repository root
ssh <user>@<host> 'mkdir -p C:/Users/<user>/arma-reforger-tools'
scp -r tools schemas data package.json <user>@<host>:C:/Users/<user>/arma-reforger-tools/
Then everything is a command inside ~/arma-reforger-tools on the target:
| Check | Command (run on the target) |
|---|---|
| Config is valid | node tools/validate-server-config.mjs /path/config.json |
| Server is up (ports bound and A2S answered too, when those checks can run) | node tools/server-status.mjs --profile <profile> --config <config> --wait 180 |
| The server itself accepts the config | node tools/probe-config-schema.mjs <config> --server-dir <serverDir> |
| RCON works | RCON_PASSWORD='<pw>' node tools/rcon.mjs players --port <port> |
For a remote target, prefix each with the SSH form from §1:
ssh -o BatchMode=yes -p <port> <user>@<host> \
'cd ~/arma-reforger-tools && node tools/server-status.mjs --profile <profile> --config <config> --wait 180'
Why on the target: the newest console.log is there, the UDP socket table is the target's, and
RCON is bound to the target's 127.0.0.1. A check run on your own machine cannot see any of them —
it would only tell you something about your own machine. The one deliberate exception is the
external reachability probe in §10.3: that one must run from outside the target's network,
because its whole point is to test the path from outside.
The tools are plain Node, no dependencies and no install step; Node 18+ from §2.2 is all they need. Copying them again after a repository update is safe — nothing in them writes to the server.
4. Three rules that decide whether it works at all
- The working directory must be the server directory. The engine resolves
./addons/coreand./addons/dataagainst the current directory, not against its own.exe. Launched from anywhere else it shows (or logs)Missing Addon/Can't find '58D0FB3206B6F859' game addon!— the GUID of the game itself. Every script below doescd(Windows) orcd/WorkingDirectory=(Linux) first. - The server must not run inside a sandbox. Under one that blocks named pipes or the TLS
certificate store, the engine hangs before its first log line or fails every backend call with
Curl error=SSL peer certificate .../ENV:[invalid]. The server then never writesServer registered with address:and is invisible to the browser. Launch it from an unrestricted shell; if your own shell is sandboxed, ask the user to run the launch command or use the widest permission the environment offers for exactly that command. - Never invent a
scenarioId, an admin id or a password. AscenarioIdis{16 hex}Missions/Name.confcopied from data/scenarios.json (38 vanilla scenarios); a mod mission's GUID comes from its.conf.meta(Namefield). A wrong GUID fails silently or exits with a mission error.
5. The interview
Ask all of this in one message. Mark the recommended answer, keep the list short, and use the user's own words for anything they already said. Question 0 was answered in §1.
Before I write the config, ten answers (recommended options are marked):
0. Where should the server run: this machine, or a remote one over SSH? [asked first, §1]
1. Server name, and should it be listed in the server browser? [required name; visible = yes]
Join password? [empty = open server]
2. Public address: let the server detect it, or pin your public IP? [auto = recommended]
3. RCON: local 127.0.0.1, external 0.0.0.0, or off? [local = recommended]
Password and permission (admin / monitor)?
4. Profile: the server default (next to the executable) or a path of your own? [default]
ownerToken.bin: copy one from another profile, or let the server create it? [create]
5. Administration: admin ids in `admins`, an admin password, or none?
[none; the owner token holder stays the owner]
6. Cross-platform: enable, disable, or a custom platform list? [disabled: PC only]
7. Maximum players? [32]
8. Which scenario should run? [Conflict - Everon]
9. Where should the folder with the config go? [next to the server executable]
A2S (Steam Query) is not one of the questions: the template already enables it on the standard
0.0.0.0:17777, which is what the server browser and monitoring use (§10).
5.1 Name, join password, visibility
game.name is mandatory and the schema enforces 1..100 characters — an empty or 101-character
name stops the server. game.password is the join password ("" = none). game.visible puts the
server in the browser; false still allows joining by code/address and is the right answer for a
test server.
5.2 Public address
Two answers:
- Let the server detect it (recommended):
"publicAddress": "". The server registers the address it sees and writes it to the log asServer registered with address: <ip>:<port>. - Pin an IP: put it in
publicAddress. Then warn the user: a pinned public IP breaks joining from the server's own LAN unless the router does NAT hairpin, and the log shows nothing — see §11.4. If the external port differs from the local one,publicPortis the external port andbindPortthe local one.
Never put a 192.168.x.x/10.x address in publicAddress on an internet-facing server, and do
not "fix" an address problem with -bindIP — it overrides both bindAddress and publicAddress.
On a VPS the address is usually the host's own public IP, so auto-detection is right there too.
5.3 RCON
Ask which binding and get a password and a permission level. Write the block only if the user wants RCON:
"rcon": {
"address": "127.0.0.1",
"port": 19999,
"password": "<no spaces, at least 3 characters>",
"maxClients": 4,
"permission": "admin"
}
127.0.0.1is the recommended binding. RCON grants full control (kick, ban, restart, shutdown) and is protected by the password alone; a server reachable from the internet withrcon.address: "0.0.0.0"is an open door — on a remote/VPS server especially. The alternative is an SSH tunnel (§11.5) or a VPN, not opening the port.0.0.0.0is the external choice. Say plainly that this exposes the remote console to the whole internet, that it must then be firewalled, and that it needs a long random password. The minimum of 3 characters is an engine limit, not a recommendation.permission:adminormonitor.monitorcan run only#players; there is no finer granularity.blacklist/whitelistexist for command filtering.- The password must not contain spaces and must be at least 3 characters, otherwise the server
writes
[RCON] Init failed... Missing password or password length < 3 characters !!!and RCON stays off. Record the password in the config only; do not echo it in the transcript or in shell history (use theRCON_PASSWORDenvironment variable for client runs).
5.4 Profile and ownerToken.bin
The profile is the server's identity, not just a log folder. Ask which one:
- Server default (recommended):
<serverDir>/profile, next to the executable. It is what the server uses when-profileis omitted; pass it explicitly so the script is unambiguous. - A path of your own: use the absolute path. One profile per server — two servers must not
share one, and copying
ownerToken.bindoes not clone a server.
Then the owner-token question. Explain, briefly:
- The token lives at
<profile>/profile/ownerToken.bin(the profile directory contains aprofilesubdirectory). It is a JWT issued by Bohemia's backend on the first successful registration; a profile that never reached the backend has none. - It is the server's ownership credential: one token = one room = one ban list. Giving the same
token to a second running server makes the two fight over the room, and the earlier one shuts
down with
DS Room/ Server not exist anymore, shutting down.... - Losing it loses the server's identity and ban list; back it up, and never publish it.
Ask: copy an existing ownerToken.bin from another profile, or let the server create a new
one? If copying, put it at <profile>/profile/ownerToken.bin before the first start, verify
the path, and make sure the source server is not running on the same token. If not copying and the
target has no token, say that the server will create one on its first registration — which needs
internet access. On a remote target, copy it over SSH/SCP, not by pasting the token into the
conversation.
To tell the user who the owner is, read only the claims — never print the token:
node -e "const p=JSON.parse(Buffer.from(require('fs').readFileSync(process.argv[1],'utf8').split('.')[1],'base64url'));console.log({ownerType:p.ownerType,ownerId:p.ownerId})" "<profile>/profile/ownerToken.bin"
ownerType: "GameIdentity" means the player in ownerId is the owner; "Anonymous" means no
player owns it (the typical dedicated-server token).
5.5 Administration
Offer three answers, and allow a combination:
- Admins in the
adminslist — the user lists identity ids (UUID, e.g.0ac6169c-15e4-4b5d-8073-08e602583069) and/or Steam ids (17 digits). Maximum 20 unique ids. Copy them exactly; never invent or "correct" one. Such players can log in without a password. - An admin password —
game.passwordAdmin, non-empty and without spaces. Required if the user chooses this. Warn: anyone with the password becomes an administrator, it sits in plain text in the config, and it is not a per-person account; theadminslist is the safer option. Login in game is#login <password>. - Defaults — no extra admins:
admins: [],passwordAdmin: "". Explain who is admin then: if the profile's token hasownerType: "GameIdentity", the owner is the admin and logs in without a password; if the token is anonymous or absent, nobody has in-game admin rights until one of the two options above is configured (RCON still works).
5.6 Cross-platform
Three answers, and the combinations are not arbitrary:
| Answer | Config | Valid? |
|---|---|---|
| Disable | crossPlatform: false, supportedPlatforms: ["PLATFORM_PC"] |
yes, vanilla and modded |
| Enable | crossPlatform: true (adds PC + XBL + PSN if absent) |
yes, vanilla and modded |
| Custom | explicit supportedPlatforms |
only the rows below |
| Combination | Allowed? |
|---|---|
| PC only | yes |
| PC + Xbox + PlayStation | yes |
| PC + Xbox | only on a modded server; PSN is never added there |
| PC + PlayStation, Xbox only, PlayStation only, Xbox + PlayStation | no |
If the user asks for a custom list that is not allowed, say so and offer the nearest allowed one.
crossPlatform: true is the simplest way to say "all consoles". If the server has mods, do not
rely on it: set crossPlatform: false and supportedPlatforms: ["PLATFORM_PC", "PLATFORM_XBL"],
because PlayStation is never added on a modded server and PC + Xbox is the only console
combination allowed there.
5.7 Maximum players
game.maxPlayers, integer 1..128. Ask; 64 is the class default, 32 is a safe answer for a first
server, 16 for a test. On a remote VPS, keep the host's bandwidth in mind before offering 128.
5.8 Scenario
Ask which mode and map, then look up the exact string — do not type a GUID from memory:
- Vanilla: data/scenarios.json / the
scenarios document. Map codenames are traps:
Edenis Everon,Cainis Kolguyev,Arlandis Arland. - A mod mission: the mission's
.conf.meta, fieldName, e.g.{GUID}Missions/X.conf.
One scenario per server; there is no rotation. If the user wants mods, the list comes from the
game's mod window (JSON tab) wrapped in [ ] — see the config skill §3; mods also change the
cross-platform answer (no PSN).
5.9 Where the folder goes
Default: a folder next to the server executable, named after the server (<serverDir>/MyServer/).
Offer a different path if the user wants one. The folder must be readable by the server process and
should be writable if the profile lives inside it. On Windows the server usually sits under
Program Files, where creating files needs an elevated shell — if that is not available, put the
folder somewhere writable and keep the absolute paths in the start script, which works from any
location as long as the working directory is still the server directory.
6. Write the config
- Copy assets/config.template.json — a complete, schema-valid config with the defaults from this skill (A2S on, PC only, Conflict Everon, 32 players).
- Apply the answers. The template already contains every key this skill sets; add the
rconblock only when RCON was requested, andgame.modsonly when there are mods. - Omit
game.gameProperties.serverMinGrassDistanceunless the user asks for a value in 50..150. The wiki allows0, but build 1.8.0.13 rejects an explicit0with... is bellow the minimum limit ... The minimum allowed is 50.and the server exits. Leaving the key out gives the default (no forced grass distance); writing50..150forces one. - Write UTF-8 without a BOM. PowerShell's
Set-Content -Encoding utf8andOut-Filewrite a BOM, and the server then fails to parse the file (JSON parse error: ... Unexpected token, reported at line 1). Use an editor that writes plain UTF-8, or a Node/Python one-liner. - Write the file on the target (or copy it there after validating it) — §3's tools are already
there, so validate it there:
node tools/validate-server-config.mjs <path>/config.json— errors mean the server will reject the file, warnings mean it starts but clamps or ignores a value.- When the tools are not available yet, check the hard rules in the config skill §3 and the failure table in §9 of the config guide.
- Optional and closest to the truth:
node tools/probe-config-schema.mjs <config.json>runs the server itself and reportsaccepted/rejected— it must run outside a sandbox (§4).
7. Place the folder and the start scripts
Layout (default profile inside the server directory shown separately):
<serverDir>/
ArmaReforgerServer.exe (Linux: ArmaReforgerServer)
addons/ the game itself — never move it
<ServerName>/
config.json
start-server.cmd Windows
start-server.sh Linux
profile/ the server-default profile (chosen in §5.4)
logs/logs_<date>_<time>/console.log
profile/ownerToken.bin
If the profile was placed inside the server folder instead, adjust the paths in the scripts.
start-server.cmd — bake the absolute server directory in; %~dp0 is the script's own folder.
Keep the command on one line: a batch file with LF-only line endings (which most editors and
write-style tools produce) breaks ^ continuations, and the script then exits without a message.
Save it as CRLF if in doubt.
@echo off
setlocal
rem Working directory MUST be the server directory: otherwise "Missing Addon 58D0FB3206B6F859".
set "SERVER_DIR=C:\Program Files (x86)\Steam\steamapps\common\Arma Reforger Server"
cd /d "%SERVER_DIR%" || exit /b 1
"%SERVER_DIR%\ArmaReforgerServer.exe" -config "%~dp0config.json" -profile "%SERVER_DIR%\profile" -maxFPS 60 -logTime datetime -logLevel normal
start-server.sh — cd first for the same reason, then exec:
#!/usr/bin/env bash
set -euo pipefail
SERVER_DIR="/home/steam/reforger/server"
cd "$SERVER_DIR"
exec "$SERVER_DIR/ArmaReforgerServer" \
-config "/home/steam/reforger/server/MyServer/config.json" \
-profile "/home/steam/reforger/server/profile" \
-maxFPS 60 -logTime datetime -logLevel normal "$@"
Make it executable (chmod +x) and never launch it as root; the server must be able to write to
the profile directory.
7.1 The command line, and what each parameter is for
| Parameter | Why it is there |
|---|---|
-config <absolute path> |
the config; absolute so it cannot resolve against the wrong directory |
-profile <absolute path> |
the profile; absolute for the same reason |
-maxFPS 60 |
without it the server tries to take all available CPU. 60..120 is the recommended range |
-logTime datetime |
timestamps become 2026-10-17 04:51:39.165, so logs from several runs can be lined up |
-logLevel normal |
normal logging. Do not add the debug firehoses (-logFS, -log-rdb-checksum, -log-scr-checksum, -logLevel debug/spam) unless the problem calls for them |
Add what the user asked for, and screen every extra parameter against
data/startup-params.json first: present is usable on 1.8.0.13,
absent is a silent no-op. Two parameters are traps: -addons is incompatible with -config
(mods go in game.mods), and -bindIP/-bindPort override both the bind and the public
address/port, so they are the wrong tool for a NAT problem.
8. Launch
8.1 Windows, local
From an unrestricted shell (not a sandbox), start the script with the server directory as the
working directory, so the script and the engine agree even before cd runs:
$folder = "C:\Program Files (x86)\Steam\steamapps\common\Arma Reforger Server\MyServer"
$serverDir = "C:\Program Files (x86)\Steam\steamapps\common\Arma Reforger Server"
Start-Process -FilePath cmd.exe `
-ArgumentList '/c', "`"$folder\start-server.cmd`"" `
-WorkingDirectory $serverDir -WindowStyle Hidden -PassThru `
-RedirectStandardOutput "$folder\server.out" -RedirectStandardError "$folder\server.err"
Two details are not optional, both found by running it:
- Redirect stdout/stderr as above. Without the redirect the server inherits the launching shell's output handles, and the call then looks like it hangs even though the server is running — the shell is only waiting for the server to close them.
- Launch through
cmd.exe /c, not by passing the.cmdfile toStart-Processdirectly: the direct form hangs the same way here even with a hidden window.
-PassThru gives a process object; the server also writes its own log, which is what §9 reads.
If the shell you are in is sandboxed, do not "try harder" here — ask the user to double-click
start-server.cmd (it sets its own working directory) or re-run the command with unrestricted
access. A sandboxed launch is indistinguishable from a broken config until you read the log.
8.2 Linux, local
chmod +x "<folder>/start-server.sh"
setsid nohup "<folder>/start-server.sh" > "<folder>/server.out" 2>&1 < /dev/null &
echo $!
8.3 Remote over SSH (the usual VPS case)
The SSH session is not the server's parent: when it closes, everything it started without detaching dies with it. Detach explicitly, or let systemd own it.
Linux target — detached launch:
ssh -o BatchMode=yes -p <port> <user>@<host> \
'cd "<serverDir>" && setsid nohup "<folder>/start-server.sh" > "<folder>/server.out" 2>&1 < /dev/null & echo started: $!'
Linux target — systemd (recommended for a server that must stay up). Write a unit on the target
(the essential parts: WorkingDirectory=<serverDir>, ExecStart=<folder>/start-server.sh,
KillSignal=SIGINT, Restart=on-failure), then systemctl enable --now, and read
journalctl -u <unit> -f — the full unit is Appendix A.4 of the
startup parameters document.
Windows target over SSH. Start-Process inside an SSH session can be killed with the session;
if the server must survive the disconnect, register it as a scheduled task and start that:
schtasks /create /tn "ArmaReforgerServer" /tr "\"<folder>\start-server.cmd\"" /sc onstart /ru <user> /rl highest /f
schtasks /run /tn "ArmaReforgerServer"
Then disconnect, reconnect, and confirm the process is still there (§9) before believing it.
The two environment rules still apply on the target: the working directory must be the server directory (§4 rule 1), and the process must not be sandboxed (§4 rule 2). A container on the VPS is a common cause of the backend failure described in §12.
8.4 Windows specifics
What differs from the Linux procedure is mostly the machine, not the config:
The install sits where you cannot write. Steam's default is
C:\Program Files (x86)\Steam\steamapps\common\Arma Reforger Server, and creating a file there needs an elevated shell. Put the folder of §5.9 somewhere writable (%USERPROFILE%\ArmaReforgerServer, say) and keep absolute paths in the script: the working directory still has to be the server directory (§4 rule 1), but that is acd, which needs no write access.A firewall rule for the game is not a rule for the server. Windows rules are normally per program, and the ones Steam creates name the client executables (
ArmaReforgerSteam.exe,ArmaReforger_BE.exe); the dedicated server is a different binary,ArmaReforgerServer.exe, so those rules do not cover it. What covers it is a rule on the UDP ports or on that exact program path — look instead of assuming:Get-NetFirewallRule -Direction Inbound -Enabled True -Action Allow | ForEach-Object { $pf = $_ | Get-NetFirewallPortFilter if (@($pf.LocalPort) -contains '2001' -or @($pf.LocalPort) -contains '17777') { [pscustomobject]@{ Rule = $_.DisplayName; Ports = (@($pf.LocalPort) -join ',') Program = ($_ | Get-NetFirewallApplicationFilter).Program } } }New-NetFirewallRuleitself needs an elevated shell, and a UAC prompt is not something an agent can answer: hand the command to the user rather than failing at it. A rule scoped toPrivatealso stops applying the moment Windows relabels the networkPublic— the same symptom as a closed port, and nothing in the log.A loopback probe never touches the firewall.
server-status --a2s-only 127.0.0.1:17777answers with the firewall fully closed, so it proves the server is up, not that the path is open. Reachability is the external probe of §10.3, and nothing on the server can stand in for it.Under a sandbox both the process list and the socket table can come back empty.
Get-Process -Name ArmaReforgerServerandnetstat -ano -p UDPmay report nothing while the server is running and answering A2S — that is the harness, not a dead server (§4 rule 2). Re-run those two, andserver-status, with the widest permission available instead of concluding that the launch failed.
Two smaller ones: game.name in Cyrillic is accepted and comes back unchanged from A2S on
1.8.0.13 — a BOM is what breaks the file, not the language (§6 rule 4); and stop the server with
the RCON shutdown (the log then ends with Game destroyed.) rather than taskkill, so the next
reader of the log can see how the run ended.
9. Watch the log until it is ready
The log lives at <profile>/logs/logs_<YYYY-MM-DD>_<HH-MM-SS>/console.log — a new directory per
launch, so always take the newest one. The sequence below is what build 1.8.0.13 writes; the whole
start takes about 15-20 s (18-20 s observed on this machine).
| Log line | Means |
|---|---|
CLI Params: ... |
what the engine actually received — the first thing to check when a flag "does not work" |
dir: './addons' + gproj: './addons/data/ArmaReforger.gproj' guid: '58D0FB3206B6F859' |
the working directory was right |
Server config loaded. then JSON is Valid |
the schema accepted the file; JSON is invalid! names the offending key |
Entities load 'worlds/...' |
the scenario's world is loading |
Starting RPL server, listening on address <ip>:<port>, fastValidation=(true|false) |
the game socket is up |
Server registered with address: <ip>:<port> |
the backend accepted the server: this is the "ready" line |
Direct Join Code: <code> / Ping Site: <site> |
what players type to join directly |
[RCON] Init, Ip address=... and Port=... |
RCON is up (absent when RCON is not configured) |
Do not conclude "ready" from the process being alive, and do not read [RCON] Init failed... as a
fatal server error — RCON is optional and stays off, the server itself runs.
9.1 The check to run (on the target)
# local target
cd ~/arma-reforger-tools
node tools/server-status.mjs --profile "<profile>" --config "<folder>/config.json" --wait 180
# remote target
ssh -o BatchMode=yes -p <port> <user>@<host> \
'cd ~/arma-reforger-tools && node tools/server-status.mjs --profile "<profile>" --config "<folder>/config.json" --wait 180'
It polls the newest console.log, stops at registration or at a fatal line, reads the expected
ports from the config, asks the operating system which UDP endpoints exist, probes A2S, and prints
a verdict that keeps the three questions apart: configValid, state/registered, and the port
table. Exit codes: 0 registered (and, when the checks ran, the expected ports bound), 1 not
ready or a port missing, 2 usage. --no-sockets and --no-a2s skip one check each — with them,
exit 0 means the log registered the server and says nothing about the ports — --log-dir reads a
specific logs_* directory instead of the newest, --out FILE writes the JSON report,
--json prints it, and --a2s-only <host>:<port> probes A2S and exits without a profile (run it
from outside the server's network). States:
ready— registration seen; go to §10.listening— the game socket is up but registration never happened: no internet, a proxy, or a sandboxed launch (Curl error=SSL peer certificate,ENV:[invalid]). Fix the environment, not the config.starting— the world is still loading; wait.failed— read the fatal line. A schema rejection names the key;Missing Addonwith58D0FB3206B6F859means the working directory was wrong.new— no log at all: the server did not start, or-profilepoints at a different directory than the one you are reading. On a remote target this is also what a launch killed with the SSH session looks like (§8.3).
Without the tools, do the same three checks by hand on the target: newest console.log for
JSON is Valid / Server registered with address:, ss -lunp | grep 2001 (Linux) or
Get-NetUDPEndpoint -LocalPort 2001 (Windows), and node tools/rcon.mjs if available.
10. Verify the ports
What must be open is UDP only — the game port, A2S and RCON are all UDP; there is no TCP listener:
| Port | Protocol | Purpose | Config key | Open it? |
|---|---|---|---|---|
| 2001 | UDP | the game (RPL) | bindPort / publicPort |
yes |
| 17777 | UDP | A2S / Steam Query — server browser and monitoring | a2s.port |
yes |
| 19999 | UDP | RCON | rcon.port |
only if rcon.address is 0.0.0.0; otherwise never |
Four different questions, with four different answers:
10.1 Did the server bind the ports?
The status tool's socket table (or ss -lunp / Get-NetUDPEndpoint), run on the target, with
the server's pid. MISSING means the server did not bind: a config or startup-parameter override,
or the wrong profile.
10.2 Does the port answer?
A2S is the only endpoint that answers without a password: the status tool sends an A2S_INFO query
to the target's loopback and prints the server name, map and player count. For RCON, log in with
the client on the target — in bash
RCON_PASSWORD='<password>' node tools/rcon.mjs players --port <port>, in PowerShell
$env:RCON_PASSWORD='<password>'; node tools/rcon.mjs players --port <port> (--json for the
machine-readable form). A silent RCON with [RCON] Init in the log means the port or address is
wrong, or a firewall eats the packet.
10.3 Is the port reachable from outside?
A bound socket says nothing about the router, the cloud security group or the host firewall, and this cannot be answered on the target itself. Run the A2S probe from a machine outside the target's network — for a remote VPS that is your own machine; for a home server, a laptop on mobile data:
node tools/server-status.mjs --a2s-only <publicAddress>:17777
Exit 0 and a name means the path is open end to end. "No answer" points at the router rule, the
security group, the host firewall, or NAT hairpin (§11.4). The same command works from the target
against 127.0.0.1:17777 as a control: if that answers and the external one does not, the server is
fine and the network is the problem. There is no application-level probe for the game port itself;
the A2S answer plus Server registered with address: is the evidence you have.
10.4 Is the server visible in the browser?
That is game.visible: true plus a successful registration plus a reachable A2S port. If the
registration is missing, the browser will not list the server regardless of the ports.
11. Tell the user how to connect
11.1 Joining
- Direct Join Code from the log (
Direct Join Code: 0340196985) — the shortest path, and the one to quote in a chat. - By address:
<publicAddress>:<publicPort>fromServer registered with address:(for a LAN-only test,<server LAN IP>:2001). - Server browser: only when
game.visibleistrue; the name isgame.name. - Password:
game.password, if set. - Admin in game:
#login <passwordAdmin>, or the owner token holder without a password.
11.2 Router port forwarding (home machine)
Forward on the router, to the server's LAN address (give it a static DHCP lease so the rule keeps working):
2001/udp→<LAN IP>:200117777/udp→<LAN IP>:1777719999/udponly if RCON was deliberately bound to0.0.0.0— and then firewall it to known addresses if the router can.
If the external port differs from the internal one, set publicPort to the external port and
bindPort to the internal one, and forward external → internal.
11.3 Host and cloud firewall
Windows Defender Firewall, one rule for the UDP ports (run as administrator):
New-NetFirewallRule -DisplayName "Arma Reforger Server" -Direction Inbound -Protocol UDP `
-LocalPort 2001,17777 -Action Allow
Linux: sudo ufw allow 2001/udp, sudo ufw allow 17777/udp, and 19999/udp only for an
external RCON (firewalld: firewall-cmd --add-port=2001/udp --add-port=17777/udp --permanent
then firewall-cmd --reload — without the reload the permanent rule is written but not active).
Open TCP for these ports nowhere: there is no TCP listener.
On a VPS, the provider's firewall/security group is the first place to look, before ufw: a
rule for 2001/udp and 17777/udp has to exist there too. The host's own public IP means there
is no router forwarding and no NAT-hairpin problem — but the security group is exactly as capable
of dropping the packets, and the symptom is the same: bound socket on the target, no answer from
outside (§10.3).
11.4 The NAT-hairpin trap (home machine)
If publicAddress holds the public IP, players from outside can join but you cannot join from
the same LAN: the client is told the public address, sends packets to its own network's public
IP, and the router must loop them back in (NAT hairpin). Many home routers cannot. The server log
shows nothing — the packets never arrive.
What to tell the user, best first: leave publicAddress empty (the server detects it); join from
inside by the LAN address; enable NAT loopback/hairpin on the router; or set up forwarding from the
public IP to the LAN one. Do not try -bindIP — it overrides both addresses and pins the problem.
11.5 RCON on a remote machine
Keep rcon.address: "127.0.0.1" and do not tunnel it with ssh -L: RCON is UDP and ssh -L
forwards TCP only, so the tunnel carries nothing and the client sees silence. The safe ways to
reach it, best first:
# 1. run the client on the target, over SSH — the traffic never leaves the host
ssh -p <port> <user>@<host> 'RCON_PASSWORD="<pw>" node ~/arma-reforger-tools/tools/rcon.mjs players --port 19999'
# 2. a VPN that carries UDP (WireGuard, Tailscale) and the client on your machine
RCON_PASSWORD='<pw>' node tools/rcon.mjs players --host <target's vpn ip> --port 19999
From the target itself, plain node tools/rcon.mjs is enough (§10.2). Never bind RCON to
0.0.0.0 on a public VPS just to avoid the tunnel.
12. Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
ssh: Permission denied (publickey) |
the target does not accept your key | ask the user to ssh-copy-id, or run the commands themselves; never paste a password or use sshpass |
A command works locally but not through ssh ... '...' |
quoting/escaping through the remote shell | put the command in a .sh/.ps1 on the target and run the file |
node: command not found on the target |
Node.js is not installed there | §2.2 — the tools need Node 18+ on the target, not on your machine |
Missing Addon window or log line, GUID 58D0FB3206B6F859 |
the working directory was not the server directory | run the start script, or cd into the server directory first |
| The same window with another GUID | an addon of the scenario or a mod is missing | check addons/ and game.mods |
Param "#/game/..." is not allowed / ... bellow the minimum limit |
the schema rejected a key or value; the line names it | fix the named key; for serverMinGrassDistance: 0 omit the key (§6) |
JSON parse error: ... (Line: 1, Char: 1) / Unexpected token at the very start of the file |
the file has a BOM or is not JSON (comments, trailing comma, mod list pasted without [ ]) |
rewrite as UTF-8 without BOM, validate with validate-server |
Unable to open server config file: <path> |
-config points elsewhere; a relative path resolves against the working directory |
use an absolute path |
[RCON] Init failed... Missing password or password length < 3 characters |
the password is empty, has spaces, or is shorter than 3 | fix rcon.password |
RCON does not answer although [RCON] Init is in the log |
wrong port/address, UDP blocked, or the client is not logged in | run the client on the target; check the socket table |
Server registered with address: never appears; Curl error=SSL peer certificate or ENV:[invalid] |
no internet, a TLS proxy, a container without a CA store, or a sandboxed launch | run outside the sandbox; check the clock, the proxy and the host's CA store |
| The server stops when the SSH session closes | the process was not detached | setsid nohup ... &, systemd, or a scheduled task (§8.3) |
| The launch command never returns, yet the server is up | the child inherited the shell's stdout/stderr (or a .cmd was passed straight to Start-Process, or has LF endings) |
launch through cmd.exe /c with -RedirectStandardOutput/-RedirectStandardError, §8.1 |
| SteamCMD installed the server into its own directory | +force_install_dir was missing or placed after +app_update |
rerun with +force_install_dir first |
| SteamCMD fails on a fresh Linux host | missing 32-bit libraries or disk space | sudo apt-get install -y lib32gcc-s1 (or install the steamcmd package), check df -h |
| Server visible outside but not joinable from the LAN | NAT hairpin, see §11.4 | leave publicAddress empty / use the LAN address |
| Server invisible in the browser | visible: false, or registration failed, or A2S is blocked |
set visible: true; read the log for registration; open 17777/udp |
Two servers: one shuts down with DS Room/ Server not exist anymore, shutting down... |
both profiles carry the same ownerToken.bin |
one profile and one token per server |
no log yet: the server has probably not started, or -profile points elsewhere from the status tool |
-profile points somewhere else, or the server never started |
check CLI Params: in the log, then the working directory |
Server exits immediately after Attempting online Game Config instead. |
the local config was rejected; the engine tried the online one and gave up | read the schema lines above it |
13. The final report to the user
Keep it short and factual, and say where it runs:
Server ready on <local machine | user@host:port>.
Folder: <serverDir>\MyServer\ (config.json, start-server.cmd)
Profile: <serverDir>\profile (ownerToken.bin, logs)
Scenario: Conflict - Everon maxPlayers: 32 crossPlatform: off
RCON: 127.0.0.1:19999 admin
Join: Direct Join Code 0340196985, or 203.0.113.10:2001
Ports: 2001/udp and 17777/udp are bound on the target and the A2S query answers with the
server name; the probe from outside answered as well.
Note: <home: forward both on the router (UDP) to <LAN IP>; you cannot join through the
public IP from the same LAN unless the router does NAT hairpin>
<VPS: open the same two UDP ports in the provider's firewall/security group>
Restart: <systemctl restart <unit> | the scheduled task | the start script>
If something is still wrong, say exactly which check failed and what the log said, rather than "it should work".
14. References
- The config guide — keys, ranges, defaults, templates, the error table, and the launch section.
- Scenarios — every
scenarioIdwith its GUID. - Startup parameters — the command line, parameter-by-parameter, plus Appendix A on Linux (SteamCMD, launch script, systemd, ports).
- RCON — permissions, commands, diagnostics.
- Profile — logs,
ownerToken.bin, saves, bans. - Backend API — what registration means, and why a sandboxed or proxied server cannot do it.
- Sibling skills: server config, RCON.
- Tools: server-status.mjs (readiness, ports,
--a2s-only), rcon.mjs, probe-config-schema.mjs, validate-server-config.mjs.