Rune Schema

A rune is a YAML file that teaches Yggdrasil Panel how to install, run, and manage one game or app. This is the field-by-field reference for writing one, plus the container patterns that real runes use.

The file

Everything lives under a single top-level gameskill: key β€” gameskill is the code and API name for a rune, and this is one of the few places you meet the spelling.

gameskill:
  id: minecraft-java
  name: "Minecraft (Java)"
  category: "Minecraft"
  docker:
    image: "eclipse-temurin:21-jre"
  startup:
    command: "java -jar server.jar nogui"

That is a valid rune. Four things are required: id, name, docker.image, and a startup command (startup.command or startup.exec β€” unless docker.keep_entrypoint is set, in which case the image’s own CMD is the command). Everything else is optional and adds a panel feature.

Key Type What it does
id string Required. The rune’s primary key. Re-uploading the same id replaces the rune. You cannot overwrite a built-in rune β€” pick a different id.
name string Required. Display name in the Runes list.
category string When you create a server without picking a realm, Yggdrasil puts it in a realm with this name, creating the realm if needed. Also shown in the Runes list.
description string Shown on the rune’s card in Runes β†’ Browse GitHub.
version int Shown as v<n> in the Runes list. Bump it when you change the file.
docker map Required. The image and how the container is built.
variables list The settings form, and the env vars/{{KEY}} values.
install map One-time setup script.
update map Update an app that is already installed.
startup map Required. How the server runs, stops, and reports readiness.
ports list Host ports to allocate and publish.
query map Player count and liveness polling.
rcon map Remote console, for the console box, schedules, bans and the Players tab.
steam map Marks the rune as a SteamCMD game.
bans map Ban/unban console commands.
players map Live Players tab: list, kick, broadcast, lock.
admin_log map Parsed admin/activity feed.
wipe map What “reset the world” deletes.
restart map In-game countdown before a safe restart.
backup map What goes into a backup archive.
anticheat map Informational anti-cheat hints.

Templating and injected values

Yggdrasil substitutes {{KEY}} placeholders in docker.image, docker.user, install.image, install.script, startup.command, and every element of startup.exec. This is plain string replacement β€” there are no conditionals, loops, or filters, and a {{KEY}} with no matching value is left in the text as-is.

The values come from the server’s variables, plus two things the panel injects:

Injected key Value
SERVER_NAME The server’s name as typed in the panel. Don’t declare a variable for it.
<NAME>_PORT The allocated host port for each declared port, name uppercased β€” game becomes GAME_PORT.
PUBLIC_URL The address this server answers on from outside: https://<its domain> when one is configured, else http://<panel host>:<port>. Empty if neither is known.

PUBLIC_URL exists because a rune cannot hardcode it. Apps that need to know their own address β€” WordPress stores it, Immich, Gitea and n8n build links and OAuth redirects from it β€” can’t be given a working default, since the port isn’t chosen until the server is created and port allocation ignores the rune’s preferred port on purpose. The panel is the only thing that knows the answer, so it supplies it.

An operator can use it too: typing {{PUBLIC_URL}} as a variable’s value in the server’s settings expands the same way, so an address doesn’t have to be retyped when it changes. Only this built-in is expanded inside operator-entered values, and only once β€” it’s a convenience, not a template language.

The same set is exported as real environment variables inside both the install and the runtime container. The runtime container additionally gets PORT_<name> (the same port, keyed by the declared name as written) and HOME=/data.

The bans, players, and restart blocks use their own placeholders ({{player}}, {{reason}}, {{id}}, {{name}}, {{message}}). Those are filled from the action, not from variables.

docker

docker:
  image: "eclipse-temurin:{{JAVA_VERSION}}-jre"
Field Type Default What it does
image string β€” Required. The runtime image. Templated.
data_path string /data Where the server’s persistent directory mounts inside the container.
user string the panel’s own uid:gid Overrides the runtime user. Templated.
keep_entrypoint bool false Run the image’s own ENTRYPOINT instead of clearing it.
extra_volumes list of strings β€” Extra container paths that each get their own persisted directory.
capabilities list of strings β€” Linux capabilities to add (cap_add). Allowlisted.
devices list of strings β€” Host devices to expose. Allowlisted.
sysctls map β€” Kernel parameters set in the container’s namespace. Allowlisted.

data_path and the working directory

Yggdrasil bind-mounts the server’s data directory into the container. Games leave data_path unset: the mount lands on /data and the container’s working directory is set to /data, so a startup command like ./DayZServer or -jar server.jar resolves against the server’s files.

Set data_path when the image stores its state somewhere else β€” /app/data for Uptime Kuma, /var/www/html for WordPress, /etc/pihole for Pi-hole. Setting it has a second effect: Yggdrasil then leaves the working directory alone, so the image’s own WORKDIR applies. That matters for images that resolve relative paths against their WORKDIR, and it’s why builtin-runes/vaultwarden.yaml sets data_path: /data even though /data is already the default β€” same mount, but the image keeps its own working directory.

user, image USER, and the /etc/passwd shim

The container always runs as a uid Yggdrasil picks. Leave user unset and it is the panel’s own service account β€” 999:982 on a typical install, though the account is made with useradd --system, so the number is whatever the OS had free (id yggdrasil tells you). That is also the account that owns the server’s data directory: the panel creates the directory as itself, and Docker never chowns a bind mount. So the default user is exactly the one that can write the server’s files, and everything the server writes stays editable from the Files tab.

An image’s own USER is overridden, not honoured. A Dockerfile that does adduser -u 1000 app followed by USER app still runs as the panel’s uid here. If the image’s code then looks itself up β€” Go’s os/user, a getpwuid call, id -un, an entrypoint that does chown app:app β€” that lookup fails, because the uid it is running as is not in the image’s /etc/passwd.

Yggdrasil normally papers over that by mounting a minimal /etc/passwd containing the uid it chose β€” but only when keep_entrypoint is false, because that file would otherwise erase the named users an image’s own init depends on (Gitea’s git, WordPress’s www-data). So keep_entrypoint: true with no user: is the one combination where an image runs as a uid it has never heard of. It surfaces as unknown userid 999, id: 'app': no such user, or an init that exits immediately and takes the container with it.

The shim is a read-only file with three entries: root, the run-as user with home /data, and nobody. It exists because the failure it prevents is so misleading β€” a Steam-based server segfaults on getpwuid() returning NULL, and reports it as CrashReporter: not found.

Three ways out, in the order to reach for them:

your image what to write
your own, or one that does not care what uid it runs as leave user unset β€” the panel’s account owns the data dir, so this is the least surprising
starts as root and drops privileges itself (linuxserver.io, s6, Gitea, WordPress) user: "0:0" together with keep_entrypoint: true
honours PUID/PGID expose them as variables defaulting to the panel’s account β€” never 1000

That last row is the most common bug in this catalogue. An image that chowns its data directory to PUID (1000 in most of them) takes the server’s files away from the panel: the app goes on working while the Files tab, restores and backups fail on that one server, with nothing announcing it.

HOME is always /data, so a non-root process has a writable home for caches. The install script runs as root regardless of this field.

keep_entrypoint

Yggdrasil clears the image’s ENTRYPOINT by default so that your startup command is the actual command. Without this, an image like cm2network/steamcmd would pass your startup command as arguments to its own entrypoint.

Set keep_entrypoint: true to run an off-the-shelf image the way a plain docker run would. The startup command then becomes optional: leave it empty and the image’s default CMD runs, or use startup.exec to pass arguments to the image’s entrypoint.

With keep_entrypoint, everything in startup becomes arguments to the image’s entrypoint β€” including command. That is easy to miss, because command reads like “the command to run” and here it is not. Yggdrasil wraps it as /bin/sh -c "<your command>" and hands those three arguments to the entrypoint, so the container really runs:

<image ENTRYPOINT>  /bin/sh  -c  "<your command>"

Whether that is right depends entirely on the entrypoint. A wrapper that execs its arguments β€” docker-php-entrypoint, most docker-entrypoint.sh scripts β€” does exactly what you meant, which is how community-runes/apps/php-site.yaml uses it. A binary that parses its arguments as a subcommand does not: it sees /bin/sh as the subcommand, prints its usage, exits non-zero, and the container restarts until the panel gives up. Nothing in the logs points at the rune β€” the give-up alert carries a usage message, and the natural suspicion is the data directory or permissions.

So with keep_entrypoint: true, reach for startup.exec (exec: ["mimir", "serve"]) or leave startup empty and let the image’s CMD run. Use command only when you know the entrypoint runs what it is given. Without keep_entrypoint there is no such trap: the entrypoint is cleared and command is the command.

extra_volumes

For images that insist on more than one mount. Each listed container path gets its own directory under the server’s data dir, named after the path (/etc/letsencrypt becomes _etc_letsencrypt), created if missing.

docker:
  image: "jc21/nginx-proxy-manager:latest"
  data_path: /data
  extra_volumes:
    - /etc/letsencrypt

Each target must be an absolute path with no ... Yggdrasil refuses targets that would shadow sensitive container directories: exactly /, /etc, /var, /var/run, /run or /home, and anything at or under /usr, /bin, /sbin, /lib, /lib64, /proc, /sys, /dev, /boot or /root. Subpaths of the exact-match denies are fine β€” /etc/letsencrypt passes, /etc does not.

capabilities, devices, sysctls

These three widen a container’s blast radius, and runes are semi-trusted β€” they get uploaded or imported from GitHub. So Yggdrasil validates them against a fixed allowlist, both when the rune is uploaded and every time it’s loaded to start a server. Anything outside the list is a validation error, not a warning.

Field Permitted values
capabilities NET_ADMIN, NET_RAW, NET_BIND_SERVICE, SYS_NICE
devices /dev/net/tun, /dev/dri, /dev/fuse
sysctls net.ipv4.ip_forward, net.ipv6.conf.all.forwarding, net.ipv4.conf.all.src_valid_mark

Capability names are matched case-insensitively. A device entry is host[:container[:perms]] β€” the container path defaults to the host path and permissions default to rwm; only the host part is checked against the allowlist.

docker:
  image: "tailscale/tailscale:latest"
  capabilities: ["NET_ADMIN"]
  devices: ["/dev/net/tun"]
  sysctls:
    net.ipv4.ip_forward: "1"

A rune cannot mount host paths. Host binds exist, but only an admin can add them per server, and they never come from rune YAML.

Two container patterns

Nearly every working rune is one of these two.

A β€” you own the command. Yggdrasil clears the entrypoint, runs your command as the panel user, and mounts /etc/passwd. Use it for game servers and for app images whose binary you can invoke directly. Files stay editable from the Files tab because the process runs as the panel’s uid. This is builtin-runes/uptime-kuma.yaml in full:

gameskill:
  id: uptime-kuma
  name: "Uptime Kuma"
  category: "Apps"
  description: "Self-hosted uptime monitoring with status pages and alerts"
  author: "yggdrasil-community"
  version: 1
  icon: "app"

  docker:
    image: "louislam/uptime-kuma:1"
    data_path: /app/data

  install:
    image: "louislam/uptime-kuma:1"
    script: |
      mkdir -p /data
      echo "Uptime Kuma data directory ready"

  startup:
    command: "node server/server.js"
    done_regex: 'Listening on'

  ports:
    - { name: web, default: 3001, protocol: tcp }

  backup:
    include: ["."]

B β€” the image owns the command. keep_entrypoint: true plus user: "0:0" runs the image exactly as its author intended: its init starts as root and drops to whatever uid it wants. Use it when the image has an s6/init layer, an entrypoint that chowns or generates config, or no shell at all. The cost is that files land under the image’s uid, not the panel’s.

docker:
  image: "lscr.io/linuxserver/freshrss:latest"
  data_path: /config
  keep_entrypoint: true
  user: "0:0"

For a shell-less (distroless, ko-built) image, pair keep_entrypoint with startup.exec β€” see builtin-runes/cloudflared.yaml and community-runes/apps/headscale.yaml.

variables

Each variable is one field in the create-server form and in Settings on the server, and one env var in the install and runtime containers.

variables:
  - key: SERVER_TYPE
    name: "Server software"
    type: select
    options: [vanilla, paper, purpur, fabric, forge]
    default: paper
  - key: RCON_PASSWORD
    name: "RCON password"
    type: string
    default: "change-me"
    secret: true
Field Type What it does
key string Required. The env var name and the {{KEY}} placeholder.
name string The form label. Falls back to key.
type string Required. One of string, int, bool, select.
options list Required for select. The dropdown entries.
default any Pre-filled value. Stringified when it reaches the container.
required bool Marks the field with an asterisk in the form.
min, max int Bounds for an int. Optional, and independent β€” you can set just one.
pattern string A regular expression the value must match.
hint string What to say when it doesn’t.
secret bool Treats the value as a secret.

There is no default type β€” a variable with no type, or an unknown one, fails validation.

type renders as accepted values
string text input, or a password field if it’s a secret anything
int number input, bounded by min/max a whole number, within bounds
bool checkbox, templated as true / false true or false
select dropdown over options one of options

The panel enforces that third column. A value that doesn’t match what the variable declares is refused with a 400 naming the field β€” Max RAM (MB): must be at least 512, got 256 β€” rather than being passed to the container to fail there in some less obvious way. An empty value means “use the default”, so optional fields you leave alone are fine.

Two details worth knowing if you’re writing a rune:

  • Bounds are checked on create against the whole form, including your defaults β€” so a default that contradicts its own type or bounds surfaces the first time someone builds a server, not at boot. On update only the fields being changed are checked, so tightening a rune’s bounds later doesn’t strand servers that already exist.
  • Only variables you declare are checked. The env a container sees has other sources (injected ports, HOME, SERVER_NAME), and those pass through untouched.

pattern β€” for values with a shape

type and min/max cover a number or a choice, but not a field whose value has internal structure. Without something to check against, the first thing to notice a malformed value is the container, and what you get back is whatever that program prints before exiting.

  - key: PRIMARY
    name: "Primary Pi-hole β€” paste address AND password together, e.g. http://192.168.1.3|mypassword"
    type: string
    secret: true
    pattern: '^https?://[^|\s]+\|.+$'
    hint: "must be the address AND the password joined by a pipe, like http://192.168.1.3|mypassword β€” not the password on its own"

That example is from nebula-sync, and it exists because of what happened without it: the field takes address|password in one string, someone pasted the password alone, and the container exited two seconds after Start with invalid pihole format β€” naming neither the field nor what was wrong with it. The panel had the value in its hand and passed it through.

Write the hint as the sentence you’d say to someone doing it wrong. It replaces the error entirely, so it should describe the value, not the regex.

Two rules the panel applies:

  • The pattern is compiled when the rune is uploaded. One that doesn’t compile is rejected there and then, rather than silently validating nothing β€” protection that isn’t there is worse than none.
  • An empty value still passes. A blank optional field means “use the default”, and a pattern must not turn leaving a field alone into an error.

secret: true does three things: the form renders a password field with show/generate/copy controls, the value is encrypted at rest in the database, and it’s masked in API responses. The variable named by rcon.password_var gets the same treatment, whether or not it says so.

Set secret: true on every sensitive variable. Do not rely on the name. The form independently renders a password-style field for any string variable whose key or label matches pass, password, secret, or token β€” but that heuristic lives entirely in the frontend and only chooses the input widget. Encryption and API masking key off secret: true (or rcon.password_var) and nothing else. A variable called API_TOKEN without the flag therefore looks protected in the form while being stored in plaintext in servers.env_json and returned unmasked by GET /api/servers/{id}.

install

Runs once, before the server may start, in a throwaway container. Its output streams live to the install log in the UI.

install:
  image: "eclipse-temurin:21-jre"   # defaults to docker.image
  script: |
    curl -fsSL -o server.jar "$URL"
    echo "eula={{EULA}}" > eula.txt

The script runs as root via /bin/sh -c, with the server’s data directory mounted at /data and the working directory set to /data β€” always /data, regardless of docker.data_path. Whatever the script writes there persists as the server’s files. The host filesystem is never exposed. A non-zero exit fails the install.

When the script finishes, Yggdrasil chowns /data to the panel’s user so the server and the Files tab can both write to it. That runs after your script, so a chown of your own does not survive β€” create the files and directories you need and let the reclaim set ownership; the install container runs as root, so it can create anything. Handing the data directory to a different uid is not something the install step can do.

A rune with no install block is marked installed immediately. A server cannot be started until its install has finished, and re-running an install on a running server recreates the container afterwards so the new files take effect.

For Steam runes, the install container gets extra help β€” see steam.

update

Updates an app that is already installed. Optional, and separate from install on purpose: most app images populate the data directory only when they find it empty, so re-running the install script on an existing installation changes nothing at all.

update:
  image: "wordpress:cli"        # defaults to docker.image
  label: "Update WordPress"     # button text; defaults to "Update app"
  script: |
    wp core update --path=/data --allow-root
    wp core update-db --path=/data --allow-root

A rune that declares this gets an Update app button on the server page, next to Update / Reinstall. Admin-only, audited, and the output streams to the same install log.

The mechanics match install β€” root, /bin/sh -c, data directory at /data β€” with three differences that matter:

  • The app is stopped first and started again afterwards if it was running, so nothing writes underneath the update. For an app-stack rune the sidecars stay up (or are brought up if the server was stopped), and the update container joins the stack network, so a database answers on its service name just as it does for the app.
  • Ownership is reclaimed, the group is not touched. install chowns /data to the panel’s user and group; an update chowns only the user. Runes that grant the app access through the group β€” WordPress hands www-data group write while the panel stays owner β€” would otherwise have that access revoked by their own update.
  • File modes are left exactly as they were. Root writes through a mode it does not own, so a rune that deliberately keeps files read-only to the app keeps them that way.

That last point is the reason this exists. The WordPress rune keeps core 644 and read-only to PHP so a compromised plugin cannot rewrite wp-login.php β€” which also stops WordPress updating itself from its dashboard (“some files could not be copied… inconsistent file permissions”). An update declared here runs outside PHP’s permission model and leaves the hardening intact.

startup

startup:
  command: "java -Xmx{{MEMORY_MB}}M -jar server.jar nogui"
  done_regex: 'Done \(.*\)! For help'
  save_command: "save-all"
  stop: "stop"
  stop_timeout: 90
Field Type Default What it does
command string β€” The command, run via /bin/sh -c. Templated.
exec list of strings β€” Raw argv, no shell. Each element templated. Takes precedence over command.
done_regex string β€” Log pattern that promotes starting β†’ running.
save_command string β€” Console command sent before stop, to flush state to disk.
stop string β€” Console command sent before the container is signalled.
stop_timeout int 30 SIGTERM→SIGKILL grace period, in seconds. Capped at 300.

command vs exec

command is the normal choice: it goes to /bin/sh -c, so pipes, export, and multi-line scripts all work. Start the real process with exec inside the script (exec java -jar server.jar) so it becomes PID 1 and receives both stdin and SIGTERM β€” without that, stop and the graceful shutdown never reach the game.

startup.exec is a raw argv list with no shell involved. Use it for images with no shell (distroless, ko-built), or to pass arguments to an image’s own entrypoint alongside keep_entrypoint. When exec is set, command is ignored.

docker:
  image: "cloudflare/cloudflared:latest"
  keep_entrypoint: true
startup:
  exec: ["tunnel", "--no-autoupdate", "run"]
  done_regex: 'Registered tunnel connection|Starting tunnel'

done_regex

A freshly started server is starting, not running. Every three seconds Yggdrasil scans the last 2000 lines of the container log for done_regex; the first match flips the server to running. Without a done_regex, a container that stays up is called running on the next poll.

Pick a line the game prints exactly once, when it’s actually accepting players. Alternatives with | are fine.

A rune with a port named web has to satisfy both. A log line is a claim, a connectable port is the thing the reader is waiting for β€” and apps do print readiness early (Tracefinity’s supervisord reports its programs up in three seconds, then spends a minute loading models). So for those runes the pattern matching is not enough: the port has to answer too. A connectable web port on its own is enough, even if the pattern never matches.

A pattern that never matches restarts nothing. If the process is alive and the line simply never appears, the server sits in starting: one “taking a long time” notification after 5 minutes, and the watcher gives up after 10. Nothing is killed, nothing is retried.

A container that exits is the other story, and telling the two apart is most of debugging a new rune. The panel marks the server stopped and retries the start three times, 15 seconds apart, then sends one alert carrying the container’s last 40 log lines. With autostart on, Docker is independently restarting the container under its own on-failure policy (at most 3). So: rising uptime in docker ps while the panel says “starting” is a done_regex problem; a container that keeps vanishing and reappearing is your process exiting, and the give-up alert carries the reason.

A done_regex that never matches is not fatal, but it is a bad time: the server sits in starting for five minutes, you get a “taking a long time” notification, and at ten minutes Yggdrasil gives up waiting and marks it running anyway if the container is still alive. Ten minutes of a wrong status badge, plus anything gated on running running late. Test the pattern against a real log.

Stopping cleanly

On stop and on restart, Yggdrasil sends save_command to the console, waits two seconds, sends stop, waits two more, then asks Docker to stop the container with stop_timeout seconds of grace before SIGKILL.

Both commands go to the container’s stdin, always β€” never over RCON, even on a rune with an enabled rcon block. That’s why the game must be PID 1 and must read commands on stdin. If your game only takes commands over RCON, save_command and stop will silently do nothing: rely on stop_timeout and a clean SIGTERM instead. (Other features β€” bans, restart warnings, scheduled commands β€” do prefer RCON and fall back to stdin. Graceful stop is the exception.)

Games with no console save command need the timeout instead. DayZ flushes its whole Central Economy state on a clean SIGTERM and uses stop_timeout: 90 rather than being killed mid-save.

config_files

The files an operator actually edits, out of everything in the server’s data directory.

config_files:
  - "server.properties"
  - "whitelist.json"
  - "config/paper-world-defaults.yml"

The Files tab turns each into a one-click shortcut. This matters more than it sounds: a rune’s variables are the handful of settings that get templated in at install, but a game’s real configuration lives in its own files β€” a server.properties has around fifty entries, and Rust keeps its config four directories down at server/yggdrasil/cfg/server.cfg. Without a shortcut, “change the MOTD” starts with knowing the layout.

Paths are relative to the server’s data directory. Absolute paths and anything containing .. are rejected when the rune is uploaded.

A listed file that doesn’t exist is not an error β€” most are written by the game on first boot, so a freshly created server has none of them, and the panel says so rather than reporting a failure.

Files ending in .properties, .env, .cfg or .conf open in a generated key/value form, with a raw-text view a click away. Anything else opens as raw text.

ports

ports:
  - { name: game, default: 25565, protocol: tcp }
  - { name: rcon, default: 25575, protocol: tcp }
Field Type What it does
name string Required. The port’s role. Becomes <NAME>_PORT and PORT_<name>.
default int The container-side port.
protocol string Required. Exactly tcp or udp.

For each entry, Yggdrasil allocates a free host port from the configured range (25000–30000 by default, see Configuration) and publishes the container port to it. The allocator walks the range sequentially and test-binds each candidate; it deliberately ignores default, because well-known game ports are the ones that get scanned. default is only the container side of the mapping.

Steam runes are the exception: they publish 1:1, so the container port equals the allocated host port. A Steam server registers its bind port with the Steam master server, so bind, publish and advertised port all have to be the same number. This is what makes -port={{GAME_PORT}} correct in a Steam rune’s startup command.

Three names are load-bearing. game is the fallback for both query and RCON. query is preferred by the query poller if present. rcon is preferred by the console if present.

services β€” app stacks

A rune with a services: block is a small multi-container app: the panel runs each sidecar on a private per-server bridge network, using its name as a DNS alias, and joins the main container to the same network. So the main app reaches its database by service name β€” set DB_HOST: db when a service is named db. Sidecars keep their image’s entrypoint and each persist to a subdirectory of the server’s data dir (.stack/<name>). Immich, Paperless, WordPress and TeslaMate are stacks.

services:
  - name: db                      # DNS alias on the stack network
    image: "postgres:17"
    data_path: /var/lib/postgresql/data
    env: { POSTGRES_PASSWORD: "{{DB_PASS}}" }   # values may reference {{VARS}}
  - name: grafana
    image: "teslamate/grafana:latest"
    data_path: /var/lib/grafana
    ports:                        # a sidecar with its OWN web UI
      - { name: web, default: 3000, protocol: tcp }
Field Type What it does
name string Required. DNS alias on the stack network.
image string Required.
env map Fixed environment; values may reference {{VARS}}.
data_path string Mount for the sidecar’s own persisted directory. Omit for a stateless worker.
command list Optional argv override.
ports list Host ports this sidecar publishes β€” same shape as top-level ports.

Most sidecars (databases, caches, workers) publish no ports: they’re internal, reached by name. Declare ports only when a sidecar has its own UI the user must open β€” TeslaMate’s Grafana is the example. Each such port is host-allocated once and reused across restarts, and appears in the server’s port list under a <service>.<name> key (e.g. grafana.web).

query

query:
  type: minecraft

type is one of a2s (or its alias source) for Steam games, minecraft (alias minecraft-java), or minecraft-bedrock. Anything else is an error at query time.

Yggdrasil polls the query host port if the rune declares one, otherwise game, and uses the result for the dashboard’s player count and liveness. Declaring a query block is also what makes a server eligible for the watchdog, which restarts a server that stops answering.

rcon

rcon:
  enabled: true
  type: minecraft
  password_var: RCON_PASSWORD
Field Type What it does
enabled bool Turns the console box and RCON delivery on.
type string minecraft or source (both Source RCON, and the default when empty), rust-websocket, or battleye.
password_var string The variable holding the password. Always stored encrypted.

Yggdrasil dials 127.0.0.1 on the server’s rcon host port, falling back to the game port β€” BattlEye shares the game port. It does not read a port out of a variable.

An enabled rcon block is what the Console tab, schedules, bans, restart warnings and the Players tab use to reach the game; without it they fall back to the container’s stdin, or aren’t offered at all. If the game needs RCON switched on in a config file, do that in the install script β€” Minecraft’s rune writes enable-rcon=true into server.properties.

steam

steam:
  anonymous: false

Declaring a steam block changes four things: host ports are published 1:1, the install script is prefixed with a chmod -R a+rwX /data (SteamCMD drops privileges and can’t otherwise rewrite steamapps/ on a reinstall), a persistent SteamCMD cache is mounted at /steamcache with HOME pointing at it, and the data dir is made world-writable for the install.

anonymous: true games just install. With anonymous: false, Yggdrasil requires an authorized Steam account β€” set one up once under Settings β†’ Steam β€” and injects its username as STEAM_USER for the install script to use. The install fails with a clear message if no account is authorized. The sentry cache in /steamcache means Steam Guard isn’t re-triggered on later updates.

Your install script issues the SteamCMD commands itself, including the app id.

bans

bans:
  ban_command: "ban {{player}} {{reason}}"
  unban_command: "pardon {{player}}"

Centralized ban management substitutes {{player}} and {{reason}} and delivers the command over RCON, or the container’s stdin if the rune has no enabled rcon block. Control characters in both values are collapsed to spaces before substitution, so a crafted player name can’t inject a second command. Omit the block for games with no console ban.

players

Adds the live Players tab. Requires an enabled rcon block β€” validation rejects the rune otherwise.

players:
  list_command: "players"
  player_regex: '^(?P<id>\d+)\s+(?P<ip>[0-9.]+):\d+\s+(?P<ping>\d+)\s+(?P<guid>[0-9a-f]+)\s*\((?:OK|\?)\)\s+(?P<name>.+?)\s*$'
  kick_command: "kick {{id}} {{reason}}"
  broadcast_command: "say -1 {{message}}"
  lock_command: "#lock"
  unlock_command: "#unlock"
Field Required What it does
list_command yes Run over RCON; its text response is parsed line by line.
player_regex yes Must compile and must have a (?P<name>...) group.
kick_command no Templated with {{id}}, {{name}}, {{reason}}.
broadcast_command no Templated with {{message}}.
lock_command no No template.
unlock_command no No template.
session_join no Regex matched against log lines; capture group 1 = player name. Opens a session-history row on a join.
session_leave no Regex; capture group 1 = name. Closes the open session on a leave. May be omitted (open sessions close when the server stops).

player_regex runs against each line of the response; lines that don’t match (headers, totals) are skipped. name is required; id, ping, guid and ip are optional and shown when captured. Any action command you leave out is not offered in the UI, so read-only listing is a valid rune.

session_join / session_leave record a persistent player session history (who was on, and when) straight from the log, so it survives after the live log scrolls away β€” the panel and Kvasir can then answer β€œwho was on yesterday / when did X last play”. This is independent of the RCON roster above and is the only way to get named history for games (e.g. Bedrock) whose names appear only in the log. A players: block may declare session tracking only (no list_command), in which case no RCON block is needed. Example (itzg Bedrock):

players:
  session_join:  'Player connected:\s*(.+?),'
  session_leave: 'Player disconnected:\s*(.+?),'

Single-quote the regex in YAML so backslashes stay literal.

events

Notable log lines worth keeping as a persistent, aggregated security/health signal β€” a WordPress xmlrpc/login attempt, an HTTP 5xx, a failed auth. Each match is rolled up per subject (capture group 1, e.g. a client IP) per hour, so a brute-force burst becomes one row per IP per hour instead of thousands of raw lines. The panel and Kvasir can then answer β€œis this site being attacked, and from where?” long after the log scrolls away β€” without storing raw access logs. Deliberately for selective, low-volume signals; don’t point it at every request.

events:
  - key: xmlrpc_hit
    label: "XML-RPC hit"
    match: '^(\S+) .*"POST /xmlrpc\.php'   # group 1 = client IP
  - key: http_5xx
    label: "HTTP 5xx"
    match: '^(\S+) .*" 5\d\d '
Field Required What it does
key yes Stable identifier for this event type.
match yes Regex matched per log line; capture group 1 (optional) = the subject rolled up (e.g. an IP).
label no Human label shown in the UI / chat (defaults to key).

Kvasir’s events lookup (data-access tier 1) reports totals and top sources per event. Pruned to 30 days.

admin_log

Turns a game’s admin/activity log into a parsed feed of joins, leaves, deaths and kills.

admin_log:
  path: "profiles/*.ADM"
  time_regex: '^(?P<time>\d{1,2}:\d{2}:\d{2})'
  events:
    - { type: "kill",  regex: 'Player "(?P<name>[^"]+)".*killed by' }
    - { type: "join",  regex: 'Player "(?P<name>[^"]+)" is connected' }

path is a glob relative to the server’s data dir; the most recently modified match is read. It can’t contain ... events is required and each entry needs a type (your own label) and a regex that compiles; an optional (?P<name>...) group names the player. The first rule that matches a line wins, and unmatched lines are dropped. time_regex pulls a timestamp off the front of the line β€” its time group if it has one, otherwise the whole match.

watchers

Default Kvasir log-watchers the rune ships with β€” your knowledge of what the app’s log looks like when something is wrong, so watching works before the admin writes a single regex.

watchers:
  - name: "PHP fatal errors"
    pattern: "PHP Fatal error"
    threshold: 1        # optional, default 1
    window_secs: 300    # optional, default 60, max 3600
    action: kvasir      # optional: notify (default) | kvasir

Each entry needs a name and a pattern that compiles (Go/RE2 syntax, matched per log line of the container’s stdout/stderr). A watcher fires when the pattern matches at least threshold lines within the last window_secs seconds; action: notify sends a notification with the matched lines, action: kvasir also hands them to the AI to explain and propose a fix (needs Kvasir configured).

At server create and (re)install the entries are seeded as ordinary per-server watchers the admin can edit, disable or delete under Settings β†’ Kvasir Watchers β€” they’re defaults, not live rune state. A resync never touches a rule that already exists, so edits and disables stick; deleting one and reinstalling restores the rune’s default, the same contract as config files.

wipe

wipe:
  paths: ["mpmissions/*/storage_*"]

Gives the rune a Wipe button and a schedulable wipe action. paths is required when the block is present: glob patterns relative to the server’s data dir, deleted on wipe. Yggdrasil refuses an empty entry, /, ., or anything containing .. β€” at upload and again at wipe time. A wipe stops the server, deletes the matches, and restarts it if it was running.

restart

restart:
  warnings:
    - { at: "15m", msg: "say ⚠ Server restart in 15 minutes." }
    - { at: "5m",  msg: "say ⚠ Server restart in 5 minutes." }
    - { at: "1m",  msg: "say ⚠ Restarting in 1 minute!" }

Enables warned restarts, manual and scheduled. Yggdrasil sorts the warnings by at descending and sends each msg that long before the restart. at is a Go duration (15m, 60s) and must parse to something greater than zero. msg is a complete broadcast command for the game, delivered over RCON or stdin β€” not just the text.

import

Declares how to bring an existing deployment of this app into a Yggdrasil server β€” the onboarding counterpart to migration (which moves servers between panels). The admin uploads the app’s own data (a site archive, a database dump) via a πŸ“₯ Import data button, and the panel runs the declared steps against the server’s data dir and, for app-stacks, its database sidecar. Everything runs in one-shot containers streamed to the build log; the server is stopped for the import and started after.

import:
  inputs:
    - key: files
      label: "Site archive (webroot)"
      accept: ".tar.gz,.zip"
      optional: true
    - key: db
      label: "Database dump (.sql or .sql.gz)"
      accept: ".sql,.sql.gz"
  steps:
    - unpack: files          # extract an archive input into the data dir (`to:` subdir, default ".")
    - db_import:             # pipe a dump into a stack database sidecar
        input: db
        service: db          # the services: sidecar name
        image: "mariadb:lts" # a client image
        command: "mariadb -h db -u {{DB_USER}} -p{{DB_PASSWORD}} {{DB_NAME}}"
    - script: "…"            # run a shell snippet in the app's own image against /data

Each inputs entry is a file the form accepts (accept is a hint; optional lets it be skipped). Each step sets exactly one verb: unpack extracts a .tar/.tar.gz/.zip (jailed to the data dir, to can’t contain ..); db_import runs a one-shot client on the stack network that pipes the dump (gzip auto-detected) into the named sidecar; script runs a {{VAR}}-templated shell snippet in the app’s image with uploads exposed as $YGG_INPUT_<KEY>. Admin-only, because a script runs code in the app’s image. Big uploads must go over the LAN β€” a reverse proxy like Cloudflare caps request bodies (100 MB on the free plan).

backup

backup:
  include: ["world", "world_nether", "server.properties", "plugins"]

Paths relative to the server’s data dir, archived into the backup tarball. Files and directories both work; there is no globbing, and a listed path that doesn’t exist is skipped rather than failing the backup. Omit include, or leave it empty, to archive the whole data dir.

anticheat

anticheat:
  antixray:
    supported: true
    config_hint: "Paper anti-xray (engine-mode 1/2) in config/paper-world-defaults.yml"
  battleye:
    supported: true
    config_hint: "BattlEye enabled via -BEpath=battleye"
  plugins_recommended: ["Grim", "Vulcan", "NoCheatPlus"]

Purely informational. antixray and battleye each take a supported bool and a config_hint string; plugins_recommended is a list of names. All of it is rendered on the server’s Anti-cheat tab and nothing acts on it.

Fields the panel parses but doesn’t use

These appear in real runes and in the schema, and they validate fine, but no panel feature reads them. Don’t rely on them:

  • author, icon β€” descriptive only.
  • steam.app_id β€” your install script passes the app id to SteamCMD itself.
  • wipe.backup_first β€” whether a wipe takes a safety backup first is chosen when you run or schedule the wipe.

query.port and rcon.port_var used to be here. They are gone from the schema: the port to query or send RCON to comes from the ports block β€” a mapping named query or rcon, falling back to game. That mapping is what actually gets allocated and published, so a second place to say it could only ever disagree with it. A rune that still sets either is not an error; the value is ignored, as it always was.

A complete rune

This is builtin-runes/minecraft-java.yaml, the rune behind the panel’s built-in Minecraft (Java) servers. It uses pattern A, and every block above except steam, players and admin_log.

gameskill:
  id: minecraft-java
  name: "Minecraft (Java)"
  category: "Minecraft"
  description: "Vanilla / Paper / Purpur / Fabric / Forge Java server"
  author: "yggdrasil-core"
  version: 7
  icon: "minecraft"

  docker:
    # The JRE image depends on the resolved Java version (see install step).
    image: "eclipse-temurin:{{JAVA_VERSION}}-jre"

  variables:
    - key: SERVER_TYPE
      name: "Server software"
      type: select
      options: [vanilla, paper, purpur, fabric, forge]
      default: paper
    - key: MC_VERSION
      name: "Minecraft version"
      type: string
      default: "latest"
    - key: JAVA_VERSION
      name: "Java runtime"
      type: select
      options: ["25", "21", "17"]
      default: "25"
    - key: MEMORY_MB
      name: "Max RAM (MB)"
      type: int
      default: 4096
    - key: LEVEL_SEED
      name: "World seed (blank = random)"
      type: string
      default: ""
    - key: LEVEL_TYPE
      name: "World type (only applies to a new world)"
      type: select
      options: [normal, flat, large_biomes, amplified]
      default: normal
    - key: DIFFICULTY
      name: "Difficulty"
      type: select
      options: [peaceful, easy, normal, hard]
      default: easy
    - key: RCON_PORT
      name: "RCON port (container-internal)"
      type: int
      default: 25575
    # Encrypted at rest and masked in the API because `rcon.password_var` below
    # names it β€” not because of its name. Any other sensitive variable would
    # need an explicit `secret: true`.
    - key: RCON_PASSWORD
      name: "RCON password"
      type: string
      default: "change-me"
    - key: ENABLE_WHITELIST
      name: "Enable whitelist (only listed players can join)"
      type: bool
      default: false
    - key: EULA
      name: "Accept the Minecraft EULA"
      type: bool
      default: false
      required: true

  # Runs once as root, with the data dir at /data. Downloads the jar the chosen
  # SERVER_TYPE needs and seeds the config files the panel depends on.
  install:
    image: "eclipse-temurin:21-jre"
    script: |
      set -eu
      # eclipse-temurin is Ubuntu-based; add curl + jq for robust JSON parsing.
      if ! command -v curl >/dev/null 2>&1 || ! command -v jq >/dev/null 2>&1; then
        apt-get update -qq && apt-get install -y -qq curl ca-certificates jq >/dev/null
      fi

      TYPE="{{SERVER_TYPE}}"
      VER="{{MC_VERSION}}"
      MANIFEST=https://launchermeta.mojang.com/mc/game/version_manifest_v2.json

      latest_vanilla() { curl -fsSL "$MANIFEST" | jq -r '.latest.release'; }
      need() { [ -n "$1" ] && [ "$1" != "null" ] || { echo "$2"; exit 1; }; }

      echo "Installing $TYPE (requested version: $VER)"
      case "$TYPE" in
        vanilla)
          [ "$VER" = "latest" ] && VER="$(latest_vanilla)"
          need "$VER" "could not resolve latest Minecraft version"
          VER_URL=$(curl -fsSL "$MANIFEST" | jq -r --arg v "$VER" '.versions[] | select(.id==$v) | .url')
          need "$VER_URL" "version $VER not found"
          SRV_URL=$(curl -fsSL "$VER_URL" | jq -r '.downloads.server.url')
          need "$SRV_URL" "no server download for $VER"
          curl -fsSL -o server.jar "$SRV_URL"
          ;;
        paper)
          # Paper v3 (fill) API β€” v2 is frozen at 1.21.x. Newest version is the
          # first patch of the first version group.
          UA="yggdrasil-installer"
          if [ "$VER" = "latest" ]; then
            VER=$(curl -fsSL -A "$UA" https://fill.papermc.io/v3/projects/paper | jq -r '.versions | to_entries[0].value[0]')
          fi
          need "$VER" "could not resolve Paper version"
          URL=$(curl -fsSL -A "$UA" "https://fill.papermc.io/v3/projects/paper/versions/$VER/builds/latest" \
            | jq -r '.downloads["server:default"].url')
          need "$URL" "no Paper build for $VER"
          curl -fsSL -A "$UA" -o server.jar "$URL"
          ;;
        purpur)
          [ "$VER" = "latest" ] && VER=$(curl -fsSL https://api.purpurmc.org/v2/purpur | jq -r '.versions[-1]')
          need "$VER" "could not resolve Purpur version"
          curl -fsSL -o server.jar "https://api.purpurmc.org/v2/purpur/$VER/latest/download"
          ;;
        fabric)
          [ "$VER" = "latest" ] && VER="$(latest_vanilla)"
          need "$VER" "could not resolve Minecraft version for Fabric"
          LOADER=$(curl -fsSL "https://meta.fabricmc.net/v2/versions/loader/$VER" | jq -r '.[0].loader.version')
          INSTALLER=$(curl -fsSL https://meta.fabricmc.net/v2/versions/installer | jq -r '.[0].version')
          need "$LOADER" "no Fabric loader for $VER"
          need "$INSTALLER" "no Fabric installer"
          curl -fsSL -o server.jar \
            "https://meta.fabricmc.net/v2/versions/loader/$VER/$LOADER/$INSTALLER/server/jar"
          ;;
        forge)
          echo "Forge requires running its installer; install the Forge universal jar"
          echo "and adjust the startup command. Not yet automated."
          exit 1
          ;;
        *)
          echo "unknown SERVER_TYPE: $TYPE"; exit 1 ;;
      esac

      # Make sure we actually got a jar.
      [ -s server.jar ] || { echo "download produced no server.jar"; exit 1; }

      echo "eula={{EULA}}" > eula.txt

      # Append a trailing newline first if the file lacks one, so we never merge
      # onto the last existing line (e.g. a hand-edited server.properties).
      # Append a trailing newline only if the file exists and lacks one. Must
      # return 0 even when the file is absent (fresh install) β€” otherwise `set -e`
      # would abort the whole install.
      ensure_nl() { if [ -s server.properties ] && [ -n "$(tail -c1 server.properties)" ]; then echo >> server.properties; fi; }
      # Pre-seed RCON + whitelist settings so the panel (and schedules/bans) can
      # connect. Minecraft fills in any remaining server.properties on first run.
      if ! grep -q '^enable-rcon=' server.properties 2>/dev/null; then
        ensure_nl
        {
          echo "enable-rcon=true"
          echo "rcon.port={{RCON_PORT}}"
          echo "rcon.password={{RCON_PASSWORD}}"
          echo "white-list={{ENABLE_WHITELIST}}"
          echo "enforce-whitelist={{ENABLE_WHITELIST}}"
          echo "difficulty={{DIFFICULTY}}"
          # World type is fixed at generation β€” only meaningful on the first run.
          echo "level-type=minecraft:{{LEVEL_TYPE}}"
        } >> server.properties
      fi
      # World seed (only on first install, before the world is generated).
      if [ -n "{{LEVEL_SEED}}" ] && ! grep -q '^level-seed=' server.properties 2>/dev/null; then
        ensure_nl
        echo "level-seed={{LEVEL_SEED}}" >> server.properties
      fi
      # Ensure the whitelist/ops files exist so they're editable in the file manager.
      [ -f whitelist.json ] || echo "[]" > whitelist.json
      [ -f ops.json ] || echo "[]" > ops.json

      echo "Installed $TYPE $VER -> server.jar"
      ls -la server.jar

  startup:
    # Re-stamp eula.txt + the mutable server.properties toggles (difficulty,
    # whitelist) from the CURRENT settings on every start, so changing them in
    # Settings + restart actually takes effect β€” install only writes them once,
    # and the toggle otherwise looks applied while the server keeps the old value.
    # World type is omitted β€” it's fixed at world generation and can't change.
    # `exec` hands PID 1 + stdin to Java so the "stop" command and signals reach it.
    # --enable-native-access=ALL-UNNAMED silences the Java 24+ "restricted
    # method ... java.lang.System::load (JNA)" warnings; harmless either way.
    command: |
      setprop() { if grep -q "^$1=" server.properties 2>/dev/null; then sed -i "s/^$1=.*/$1=$2/" server.properties; else echo "$1=$2" >> server.properties; fi; }
      echo eula={{EULA}} > eula.txt
      setprop difficulty {{DIFFICULTY}}
      setprop white-list {{ENABLE_WHITELIST}}
      setprop enforce-whitelist {{ENABLE_WHITELIST}}
      exec java -Xmx{{MEMORY_MB}}M --enable-native-access=ALL-UNNAMED -jar server.jar nogui
    done_regex: 'Done \(.*\)! For help'
    # Flush chunks to disk before shutting down, then stop cleanly (the "stop"
    # command already saves, but save-all first makes a graceful restart robust).
    save_command: "save-all"
    stop: "stop"

  query:
    type: minecraft

  # RCON itself is switched on by the install script, in server.properties.
  rcon:
    enabled: true
    type: minecraft
    password_var: RCON_PASSWORD

  config_files:
    - "server.properties"
    - "whitelist.json"
    - "ops.json"
    - "config/paper-world-defaults.yml"

  # The host ports are allocated from the panel's range; 25565 and 25575 are only
  # the container side of each mapping.
  ports:
    - { name: game, default: 25565, protocol: tcp }
    - { name: rcon, default: 25575, protocol: tcp }

  anticheat:
    antixray:
      supported: true
      config_hint: "Paper anti-xray (engine-mode 1/2) in config/paper-world-defaults.yml"
    plugins_recommended: ["Grim", "Vulcan", "NoCheatPlus"]

  bans:
    ban_command: "ban {{player}} {{reason}}"
    unban_command: "pardon {{player}}"

  backup:
    include: ["world", "world_nether", "world_the_end", "server.properties", "whitelist.json", "ops.json", "plugins"]

  # "Wipe" deletes the world folders; a fresh world regenerates on next start
  # (same seed if level-seed is set, otherwise random). Keeps server.properties,
  # whitelist and ops.
  wipe:
    paths: ["world", "world_nether", "world_the_end"]
    backup_first: true

  # In-game countdown broadcast before a safe restart.
  restart:
    warnings:
      - { at: "15m", msg: "say ⚠ Server restart in 15 minutes." }
      - { at: "5m",  msg: "say ⚠ Server restart in 5 minutes." }
      - { at: "1m",  msg: "say ⚠ Restarting in 1 minute β€” find a safe spot!" }

Authoring a new rune

Start from a rune that already works. builtin-runes/ has the five that ship with the panel; community-runes/ has thirty-odd more, split into games/, apps/ and databases/. Copy the closest one and change the image, variables and startup command.

  1. Write the YAML. Give it a new id β€” you cannot overwrite a built-in rune, and re-uploading an existing id replaces that rune in place.
  2. Get it into the panel. Either Runes β†’ Carve a rune (upload) and pick your .yaml (512 KB maximum), or Runes β†’ Browse GitHub to install straight from a repo folder β€” it defaults to this project’s community-runes/, and takes any owner/repo, path and branch. Pterodactyl eggs and XML definitions import under Import egg / Import XML. All of these need admin rights.
  3. Read the validation errors. Yggdrasil parses and validates on upload and refuses anything invalid, naming the field: a missing startup.command, a variable with no type, a port with no protocol, a player_regex that doesn’t compile or lacks its name group, a capability outside the allowlist. The same validation runs every time a server starts, so a rune that uploads is a rune that loads.
  4. Test it. Create a server from the rune, watch the install log for the script’s output, then start it and watch the console. The two things that usually need a second pass are the startup command’s paths (working directory, library paths) and done_regex β€” if the server plays fine but the badge stays on starting, the regex is wrong.

Iterate by re-uploading the same id. Existing servers pick up the new YAML on their next start, since the container is rebuilt from the rune every time.

See also

  • Configuration β€” the port range runes allocate from
  • API reference β€” the /api/gameskills endpoints behind the Runes page
  • Networking β€” making an allocated port reachable