Backups and schedules

Where backups go, what is actually in them, how to prove one is restorable, and how to put any of it — plus restarts, updates and in-game warnings — on a cron schedule.

Backup targets

A target is a place archives are stored. Admins configure them under Settings → Integrations → Backup targets; there are four kinds.

Type UI label What it is
local Local / NFS / CIFS mount A directory on the host. Also the right choice for an NFS or CIFS share you have already mounted — point the path at the mountpoint.
sftp SFTP An SFTP server, reached over SSH with a username and password. Port defaults to 22.
smb SMB / CIFS (direct) An SMB2 share, spoken natively with no host mount. Needs a share name. Port defaults to 445.
pbs Proxmox Backup Server An existing PBS you already run. Deduplicated, incremental snapshots instead of nightly full archives. Port defaults to 8007. Its own section below.

Choose + New target, name it, pick the type, and fill in what it asks for. Local wants a directory path (/mnt/backups); Yggdrasil creates it if it does not exist. SFTP and SMB want a host, optional port, username and password, plus a remote path — relative to the login directory for SFTP, and to the share root for SMB. SMB also wants the share name.

Then use Test. It opens the target and lists it, which catches the whole chain — DNS, port, credentials, share name, permissions — before a backup depends on it. A failure comes back with the underlying error rather than a shrug.

Target credentials are encrypted at rest with AES-256-GCM, keyed from the panel’s secret. The target list decrypts only enough to show you the path and host; passwords are never sent back to the browser and never logged.

Two things to know about the SFTP target: it authenticates with a password (not a key), and it does not pin the host key. It suits a homelab NAS on your own network.

Proxmox Backup Server

The first three targets all store the same thing: one gzipped tar per run, written whole every time. That is simple and it is fine — until the server is 40 GB and the schedule is nightly.

A PBS target works differently, and the difference is the entire point. Instead of building an archive and uploading it, Yggdrasil hands the data directory to proxmox-backup-client, which splits it into content-defined chunks and uploads only the chunks your datastore does not already have. On a real 57 MB directory, measured:

Run Transferred
First backup 57.5 MB
Again, nothing changed 0 B
After editing one small file 8.2 MB

Nightly backups of a large, mostly-static game world therefore cost close to nothing after the first one, and every night is still a full restore point rather than an increment that depends on the ones before it.

This connects to a PBS you already run. Yggdrasil does not host one.

What you need on the PBS side

Create an API token — Configuration → Access Control → API Tokens — and note the secret when it is shown. It is displayed once.

Then grant permissions, and read this part twice, because it is the thing that goes wrong:

Give the role to BOTH the user and the token. A token’s effective permissions are the intersection of its own and its user’s. Granting DatastorePowerUser to only the token leaves it with nothing, and the panel reports permission check failed - missing Datastore.Audit|Datastore.Backup, which reads like a wrong password.

Use DatastorePowerUser, not DatastoreBackup. DatastoreBackup can write snapshots but cannot prune them, so retention silently never runs and the datastore grows without limit. If that happens, Yggdrasil warns on the server’s notification channel — “Backup kept, but retention could not run” — rather than letting you find out when the disk fills.

If your PBS uses its own self-signed certificate (the default), you also need its fingerprint. Get it from Administration → Certificates, or on the PBS host:

proxmox-backup-manager cert info | grep -i fingerprint

Filling in the target

Field Example Notes
Host pbs.lan Hostname or IP, and it must resolve from the panel’s host. IPv6 works; the panel brackets it for you.
Port (blank) Defaults to 8007.
Auth ID ygg@pbs!panel The full token id, including the !name part.
API token secret (from PBS) Encrypted at rest, never sent back to the browser.
Datastore backups The datastore name, not a path.
Namespace (blank) Optional. Empty means the datastore root.
Certificate fingerprint aa:bb:… Required for self-signed certificates.

Then use Test. It lists the datastore’s backup groups, which exercises DNS, TLS, the fingerprint, the auth id, the token secret and the datastore name in one call — so a green result means a backup will reach the same place. Use it before attaching a schedule; it fails in seconds where a backup fails after the container has started.

One name to be careful with: on a Tailscale network, a *.ts.net MagicDNS name only resolves if MagicDNS is actually enabled on the panel’s host. If it is not, the backup fails with dns error: no record found — which reads like the PBS server is down. The Tailscale IP (100.x.y.z) always works, and the panel reaches it from inside the backup container the same way it reaches any other address.

How snapshots are named

Each panel server gets its own PBS backup group, host/<server-name>-<short-id>. The group is what makes the deduplication work: a snapshot is compared against the previous snapshot in the same group.

One consequence worth knowing: renaming a server starts a new group. The old snapshots stay where they are and remain restorable from PBS directly, but the next backup has nothing to dedupe against and transfers everything once.

What is different from the other targets

  • Retention runs on the PBS server. “Keep latest N” becomes --keep-last, exactly. “Keep days” becomes --keep-daily, which keeps the newest snapshot of each of that many days that have one — not a rolling window of calendar days. With one backup a day the two are the same thing; with several a day they are not.
  • Deleting a snapshot does not free space immediately. Chunks are shared between snapshots, so nothing can be reclaimed until the PBS server runs garbage collection.
  • Verify is PBS’s job. The panel’s own verify re-reads a tar; for PBS, run a Verify job on the datastore, which checks the stored chunk checksums properly.
  • Download is not offered. A snapshot is deduplicated chunks plus an index, not a file, so there is nothing to hand a browser. Restore it to the server, or use proxmox-backup-client against your PBS directly.
  • Restore writes in place over the running server’s data directory, after stopping the container — same as the other targets.
  • A .pxarexclude-cli file appears in a restored directory when the rune limits the backup to certain paths. It is the client’s own record of what was excluded, and it is harmless.

Requirements

The panel host must be amd64. Proxmox publishes the backup client for amd64 only, so a Raspberry Pi panel cannot use a PBS target; it will say so rather than failing at the first backup.

The client itself runs in a small container the panel pulls once (ghcr.io/kristianwind/yggdrasil-pbs-client), so there is nothing to install on the host. On a network that cannot reach GHCR, mirror it and set docker.pbs_client_image in the panel’s config.

What a backup contains

A backup archives the server’s data directory — the volume mounted at /data in the container.

Which parts, exactly, is the rune’s business. A rune (the code and API call it a gameskill) may carry a backup block naming the paths worth keeping, relative to /data:

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

When backup.include is absent, the whole data directory is archived. When it is present, only those top-level paths are — an included path that does not exist on disk is skipped rather than failing the run. Paths are stored relative to the data directory, so an archive restores cleanly into a fresh install.

Only regular files and symlinks go in. Sockets and device nodes are skipped.

Backups do not include the server’s environment variables, port allocations, mods list or any other panel-side state — that lives in Yggdrasil’s database, not in /data. A backup restores a world; it does not recreate a server.

The archive format, stated plainly

A backup is a plain gzip-compressed tar.tar.gz, nothing more. It is streamed straight from the data directory to the target through a pipe, so a large world never lands on the host as a temporary file.

The archive itself is not encrypted. Only target credentials are encrypted at rest. Anyone who can read the target directory, share or SFTP account can read your world files, your configs, and any secrets those configs contain. Set this expectation now, not during an incident:

  • Do not treat an untrusted or shared remote as safe for backups.
  • If you need encryption at rest, it has to come from underneath — an encrypted filesystem, an encrypted volume on the NAS, or a target only you can reach.

The upside of a boring format is that you are never locked in. Any machine with tar can open a Yggdrasil backup, with or without the panel:

tar -tzf 20260714-031500.tar.gz          # list what's inside
tar -xzf 20260714-031500.tar.gz -C ./out # extract it anywhere

Naming and layout

Archives are stored under a readable folder per server rather than a bare UUID, so a directory listing on the NAS means something:

<server-name-slug>-<first 8 chars of server id>/<YYYYMMDD-HHMMSS>.tar.gz

For example mc-survival-3f9a1c2b/20260714-031500.tar.gz. The name is lowercased with anything that is not a letter or digit collapsed to a single dash; the short ID keeps the folder unique if two servers share a name. Timestamps are UTC.

Retention works off the database, not off directory listings, so this layout is safe to browse, and safe to change in future.

Running a backup

Open a server, go to the Backups tab, pick a target and choose Back up now. The run is asynchronous: a row appears immediately as pending, moves to running, then done (with the archive path and size) or error (with the reason). Either outcome sends a notification — ✅ with the size, or ❌ with the error.

Running a backup needs server.backup on that server. See Users and permissions.

To cover several servers at once, use the checkboxes on the Servers list and the bulk backup action: pick one target and it starts a backup for every selected server you have server.backup on and that is installed.

Server backups tab

Restoring

On the Backups tab, Restore on a completed backup. Yggdrasil stops the container first (with a 30-second grace period) so files are not swapped under a running game, marks the server stopped, streams the archive from the target and extracts it over the data directory. You start the server again yourself when you are ready.

Restore overwrites. Files in the archive replace files on disk; files not in the archive are left alone. It is not a rollback to a point in time — it is an extraction on top of whatever is there. If the current state matters, back up before restoring.

Extraction is guarded against archives that try to escape the data directory — worth knowing about if you ever restore an archive you did not create:

  • Any entry whose resolved path lands outside the destination is rejected outright.
  • A symlink entry pointing outside the destination is rejected, so a later entry cannot be written through it onto host files.
  • Regular files are opened with O_NOFOLLOW, so if the path already exists as a symlink the write fails rather than following it.

Any of these aborts the restore with an error, mid-way. An archive is not trusted on the grounds that it came from your own target.

Retention

Retention is per target, set when you create it, and applied after every successful backup to that target.

  • Keep latest N — keep the N newest backups. 0 disables the count rule.
  • Keep days — keep anything newer than this many days. 0 disables the age rule.

The rules are OR’d: a backup survives if it is within the newest N or newer than the day cutoff. Setting both is a floor plus a window — “keep at least the last 5, and anything from the last 30 days”. With both at 0 there is no policy and nothing is ever deleted.

Retention deletes the archive from the target and the row from the panel, and it only ever considers completed backups for that one server on that one target.

On a Proxmox Backup Server target the same two settings are applied by PBS’s own prune, which is chunk-aware; the panel then removes the rows for whatever PBS deleted, so the list stays equal to what is actually restorable. The mapping and its one wrinkle are in Proxmox Backup Server.

Verify

Verify on a completed backup downloads the archive and streams it through gzip and tar to io.Discard — reading every byte, writing nothing anywhere. It is a real integrity check, not a size or timestamp comparison: a truncated archive, a flipped bit or a half-written upload fails at the gzip CRC or on a short tar entry. On success you get the file count and total uncompressed size; on failure you get the error that killed it.

This applies to the archive targets. A Proxmox Backup Server snapshot is verified by PBS itself, against the chunk checksums it stored — a stronger check than re-reading a tar, and one the panel would only duplicate badly. Run a Verify job on the datastore; Yggdrasil’s Verify button says so rather than pretending.

Verify writes nothing to disk and does not touch the server. Nothing stops you running it on a live server mid-session.

The result is stored on the backup, and the list shows a badge next to it:

  • ✓ verified — the archive decompressed in full.
  • ✗ corrupt — it did not. Take a fresh backup.
  • no badge — never verified. Unknown, not good.

A target that is unreachable is not a corruption verdict; that comes back as a fetch error and leaves the badge alone.

Nightly auto-verify

Settings → Integrations → Backup verification has one switch: Verify the latest backup of each server nightly.

It is off by default, and deliberately so — it downloads each archive from its target, which costs real bandwidth on a remote SFTP or SMB store. Turn it on if your target is local or your link is cheap.

When on, the pass runs at most once per day and only checks the newest completed backup per server, which keeps the cost bounded and predictable. A background loop wakes every 30 minutes and runs the pass if it has not run today, so a panel restart or a box that was asleep at 3am catches up rather than skipping the day. The day is stamped before the pass starts, so a slow pass cannot re-trigger itself.

If any of those newest backups is corrupt, you get one notification naming the affected servers and their errors. A clean pass is silent. The panel shows the date of the last pass it started, not the last one that finished: it reads the same stamp, so the date appears the moment the pass begins.

Verifying only the newest backup is a deliberate trade. It answers “if I needed a backup right now, would it work?” — not “is my whole history intact”.

The backup-coverage nudge

The Dashboard shows admins a warning when installed servers have no recent backup: “N servers have no recent backup”, listing each one with how long ago its last backup was, or never.

“Recent” means a successful backup within the last 7 days by default. Only installed servers count, and only backups that reached done — a server whose backups have been failing for a week shows up here, which is the point. The card lists the servers by name so you can go fix them. It disappears when everything is covered.

Schedules

Schedules in the sidebar. A schedule is a cron expression, an action, a scope, and some arguments.

Schedules

Cron

Standard 5-field cron (min hour dom month dow), or 6 fields to get a leading seconds field. The editor rejects anything that is not 5 or 6 fields before saving; the full parse happens when the schedule is registered. Times are the panel host’s local time.

0 4 * * *      every day at 04:00
0 */6 * * *    every six hours
30 3 * * 1     Mondays at 03:30

New schedules start enabled. Disable keeps the schedule but stops it firing; the cron is rebuilt from the enabled schedules whenever anything changes.

Scope

Scope Runs against
Server That one server.
Realm Every server in that realm, at the time it fires.
Global Every server.

Realm and global schedules pick up servers added later — that is the point of them, and the thing to think twice about before scoping a stop globally.

Realm and global schedules are admin-only, to create and to see: a delegate’s schedule list only contains server-scoped schedules for servers they hold server.schedule on.

Some rows are managed by a per-server control (the auto-restart toggle writes one). Those are hidden from the Schedules page on purpose, so they cannot be hand-edited into a state the control does not expect.

Actions

Action What it does
backup Backs the server up to the chosen target. Same path as the Backups tab, retention included.
restart Recreates the container and starts it — not a plain Docker restart, so rune, env and mod changes land too. Falls back to a plain restart if recreation fails.
start Starts the existing container. Errors if the server has no container yet.
stop Stops the container, 30-second grace. Skips if there is no container.
command Sends a raw command over RCON, falling back to the container’s stdin for games without it.
message Renders a message template and sends it to players the same way.
update Re-runs the install/update (SteamCMD and the like). Synchronous, so the run log reports the real outcome.

The API also accepts a wipe action, which resets world/persistence as the rune defines it and needs server.control like the Wipe button does. The schedule editor does not offer it; wiping is normally done from a server’s Wipe button, which asks for confirmation and runs once, immediately.

A warned restart — optionally back up, broadcast the rune’s countdown to players, then recreate — is a server’s Safe restart action rather than something the schedule editor offers. The backup runs first, before the countdown starts, so the archive captures the world as it stood when you pressed the button rather than at the end of the warning window.

A restart schedule can do the same via the API by passing warn: "true" in its arguments; because the countdown blocks for minutes, that variant runs in the background and the run log records it as started rather than finished.

Message templates

message sends a template rather than free text, so the wording lives in one place. Templates are edited under Settings → Integrations → In-game message templates, and Yggdrasil seeds five on first boot: Restart warning, Restart countdown, Backup warning, Maintenance notice, Update warning.

A template body is the full console/RCON command, so it works across game types — you tune the syntax per game if you need to:

say Server restarting in {{minutes}} minutes. Please find a safe spot.

{{key}} placeholders are substituted at send time. Three keys are substituted: {{server_name}}, {{minutes}} and {{seconds}}.

The panel fills in {{server_name}} itself. For the rest, the schedule editor reads the template you picked and gives you an input for each placeholder it uses — pick Restart countdown and you’re asked for a {{seconds}} value; pick Backup warning, which uses only the server name, and you’re asked for nothing. The body is shown under the picker so you can see what you’re filling in.

A placeholder nothing fills in is left in the text untouched, and a template still holding one at send time is not sent: the run log records error — template needs a value for {{seconds}} instead. A message going out to your players with a gap where a number should be is worse than one that doesn’t go out, and the run log is where you find out why.

Warning players before a scheduled restart

restart offers warn players with the in-game countdown first, on by default. It runs the same safe-restart path as the button on the server page: Yggdrasil broadcasts the rune’s restart.warnings countdown in-game, waits it out, and then restarts. The run log records warned restart initiated when the countdown starts, because the countdown outlives the run itself.

A game whose rune declares no countdown has nothing to broadcast, so it restarts straight away — the setting costs nothing there, but it buys nothing either. A schedule scoped to a realm or to all servers can span both kinds.

Untick it for a restart that should happen immediately and unannounced. That is the right choice at 04:00 on an empty server, and the wrong one at 20:00.

Skipping when players are online

restart and update offer skip if players are online. When it is on, Yggdrasil queries the server’s player count before acting and skips if anyone is connected; the run log records skipped — players online.

This needs the rune to define a query protocol. If the count cannot be determined the action goes ahead — an unanswerable query is not treated as “players online”. Do not lean on this as a safety interlock on a game Yggdrasil cannot query.

Run history

Log on a schedule shows its recent runs: when it fired, which server, the action, the status and a detail string. The panel keeps the last 100 runs per schedule and shows the 50 most recent.

Every firing records one row per target server, so a realm-scoped backup across six servers writes six rows. Status is one of:

  • ok — done, with a detail like restarted or backup completed.
  • skipped — deliberately not done: players online, no command configured, already stopped.
  • error — tried and failed, with the reason.

A scheduled backup runs synchronously and reports its real outcome, so ok here means the archive reached the target and error carries the reason it didn’t. That makes the run log a usable answer to “did last night’s backups actually run?” — you don’t have to cross-check every server’s Backups tab.

A warned restart is the one action that reports what it started rather than what it finished: the countdown runs for minutes in the background, so the row says warned restart initiated as soon as the warning goes out.

A firing that matches no servers records a single skipped row with no servers in scope.

Run now fires a schedule immediately, ignoring its cron time. It is the way to test one without waiting for 4am.

One notification per firing

A firing sends one summary notification, not one per server:

✅ Schedule "nightly-restart" (restart): 5 ok, 1 skipped, 0 failed
  ok: alpha, beta, gamma, delta, epsilon
  skipped: zeta (players online)

The icon is ✅ when everything worked, ❌ if anything failed, ⏰ if nothing ran.

Only state-changing actions notify: start, stop, restart and update. Backups stay quiet at the schedule level because runBackup already emits its own ✅/❌ per server, and message and command are in-game only — announcing them to your phone would be noise. See Notifications for wiring up channels.

Who can do what with schedules

Creating a schedule needs server.schedule on the target server, plus the scheduled action’s own permission — so a Schedule grant cannot be escalated into powers the delegate does not otherwise have:

Action Extra permission needed
command server.console
start, stop, restart, update server.control
wipe server.control
backup server.backup
message none beyond server.schedule

Each action’s requirement matches the gate on the equivalent direct endpoint, so scheduling something never grants more than doing it by hand would. The table is exhaustive and fails closed: an action with no entry is rejected rather than waved through, and a test asserts every known action appears here — a new action cannot reach the scheduler ungated.

Admins skip this check, as they skip all of them.

There is an asymmetry here worth knowing before you hand out server.schedule:

Update, delete and run-now are admin-only. Not permission-checked — admin-only, flatly. A delegate with server.schedule on a server can create a schedule there and read its run log, but cannot then edit it, disable it, delete it, or trigger it. Once created, it is yours to manage.

The practical consequences:

  • A delegate who mistypes a cron expression cannot fix it. They need an admin, or a second schedule.
  • A delegate cannot turn off a schedule they created — not even their own runaway one.
  • server.schedule is closer to “may propose recurring work” than “may manage schedules”.

If you want someone to own schedules end to end, they need to be an admin. If you want them to have no say, do not grant server.schedule.

See also