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
typeor 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.
installchowns/datato the panel’s user and group; an update chowns only the user. Runes that grant the app access through the group β WordPress handswww-datagroup 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.
- Write the YAML. Give it a new
idβ you cannot overwrite a built-in rune, and re-uploading an existingidreplaces that rune in place. - 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’scommunity-runes/, and takes anyowner/repo, path and branch. Pterodactyl eggs and XML definitions import under Import egg / Import XML. All of these need admin rights. - Read the validation errors. Yggdrasil parses and validates on upload and refuses anything
invalid, naming the field: a missing
startup.command, a variable with notype, a port with no protocol, aplayer_regexthat doesn’t compile or lacks itsnamegroup, a capability outside the allowlist. The same validation runs every time a server starts, so a rune that uploads is a rune that loads. - 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 onstarting, 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/gameskillsendpoints behind the Runes page - Networking β making an allocated port reachable