Downloading Worlds
Hand players a world as a single-player save over an expiring download link.
/worlds download <world> packs a world into a save a player can drop into their client's saves folder, and sends them a clickable link to fetch it. The feature is off by default — enabling it hands out world data.
Archives are served one of two ways. The built-in server (storage: local) opens a port on the machine and streams the file itself. Pre-signed links (storage: s3) upload the archive to the bucket your backups already use and send the player a signed, expiring URL — no port, no size limit, and the download never touches the game server.
Why an export is needed
Since Paper 26.1 a server world is no longer its own folder. Every world is a dimension of the main level:
worlds/<level-name>/
├── level.dat
└── dimensions/minecraft/
├── overworld/
├── the_nether/
├── the_end/
└── <your-world>/ <- a BuildSystem world lives here
├── region/
└── data/minecraft/world_gen_settings.datCopying that folder out by hand gives you something the client cannot open. It has no level.dat, its chunks sit under the wrong dimension, and its saved data is in the wrong place: Paper keeps a per-world copy of the level-scoped files (world_gen_settings.dat, game_rules.dat, weather.dat, …) inside each dimension folder, while a single-player save keeps one set at the root. A client that finds no generator settings there refuses the world outright.
The export rebuilds all of that. The world becomes the save's overworld, gets a level.dat named after it, and its data is split by scope:
<world>/
├── level.dat from the main level, renamed and scrubbed
├── data/minecraft/ level-scoped: world_gen_settings, game_rules, weather, …
└── dimensions/minecraft/overworld/
├── region/ entities/ poi/
└── data/minecraft/ dimension-scoped: raids, world_border, chunk_ticketsA world still stored in the pre-26.1 flat layout already is a save, so only its level.dat is rewritten - it keeps its own nether and end, and dimensions belonging to other worlds are left behind.
Enabling downloads
enabled
false
Turns downloads on.
storage
local
local for the built-in server, s3 for pre-signed links.
port
8080
Port the built-in server listens on. Must be reachable by players. Ignored on s3.
url
"http://localhost:8080"
Base address players are linked to. Ignored on s3.
behind-proxy
false
Identify clients by X-Forwarded-For. Enable only behind a reverse proxy. Ignored on s3.
expiration-minutes
30
How long a link stays valid. The archive is deleted when it expires.
max-size-mb
2048
Largest single export. local only.
max-storage-mb
8192
Budget shared by all live downloads. local only.
max-concurrent-downloads
3
Transfers served at once. local only.
Set url to whatever players actually reach — a domain, or the address of a reverse proxy. The server speaks plain HTTP, so put it behind a proxy and point url at the HTTPS address if links leave your network. When you do, also set behind-proxy: true, or every request looks like it came from the proxy and link pinning and rate limiting stop telling players apart.
Toggling enabled or storage takes effect on /config reload; no restart needed.
Serving from S3
storage: s3 reads the credentials, bucket and endpoint from the root storage.s3 section — there is nothing to configure twice, and any S3-compatible service works. Backups do not have to be on S3; the two features pick their backend independently. If storage.s3 is incomplete, downloads stay off and the console says which key is missing.
What changes:
No port. Nothing has to be reachable from outside, so this works behind NAT or on a host that will not open one.
No size limit. Archives upload in parts, so
max-size-mbandmax-storage-mbdo not apply and a world of any size can be downloaded.Your uplink carries the world once, on upload, instead of once per player fetching it.
The link is https, signed by S3 and valid for
expiration-minutes.
Uploads live under the downloads/ prefix and are deleted when their link expires. Anything left by a crash is cleared at the next startup, so the bucket does not accumulate archives.
A pre-signed link is a pure bearer token. Unlike a local link it cannot be pinned to one client or rate limited, so anyone it is forwarded to can use it until it expires. Keep expiration-minutes short.
Downloading a world
Command:
/worlds download <world>Permission:
buildsystem.download(OP, supports.self/.other)
The world is saved first - waiting for its chunks to reach disk, so the archive never catches a region file mid-write - then zipped in the background. An animated progress bar runs in the action bar while it packs, showing real progress - bytes packed against the world's total size - with a sweeping highlight and spinner so a pause on one large region file still reads as working. On s3 the bar then switches to the upload and tracks that the same way, so it never sits full while work is still running. When it is ready the player gets a clickable message; the link opens in their browser. Only one export per player runs at a time.
Restyle the bar through worlds_download_progress and worlds_download_uploading in messages.yml, which take %world%, %bar%, %percent% and %spinner%. The finished message is worlds_download_finished; its %button% placeholder marks where the clickable link goes, and only that segment carries the click and hover - its text is worlds_download_button.
Extract the archive into .minecraft/saves/ and the world appears in the single-player list.
What is exposed
Treat a link as private. Everything else is closed off.
With storage: s3, the link is signed by S3 and dies with its expiry; the pinning, rate limiting and transfer caps below are properties of the built-in server and do not apply. Everything about what an export contains applies either way.
Each export is reachable only through a random 256-bit token that forms the entire URL path. Requests cannot name a file, so nothing outside
plugins/BuildSystem/downloadsis reachable.Archives and their links are deleted together: on expiry, on reload, and on shutdown. Anything left behind by a crash is wiped at startup, since its link did not survive the restart.
An unknown or expired link returns a bare
404.A link is pinned to the first client that uses it. Forwarding it to someone else does not work, while the player who asked for it can still retry or resume.
Requests are rate limited to 30 per minute per address, and concurrent transfers are capped, so one host cannot saturate the endpoint. A request over the transfer cap gets
503withRetry-After.Exports never include player data (
players/,playerdata/,stats/,advancements/), so downloading the main world does not hand out the whole server's inventories. The exportedlevel.datalso drops the server's brand, version and server-side datapacks.
buildsystem.download hands out the full contents of a world. Grant it with the same care as buildsystem.backup.
Last updated