Security

How Playkeeper protects your server and its dashboard, what it exposes and why, and how to report a vulnerability privately.

From SECURITY.md in the repository, for Playkeeper 0.4.2.

Playkeeper operates game servers and world data on the user's host. Treat the web panel as a sensitive administrative interface, not a public demo.

Reporting a vulnerability

Report vulnerabilities privately through GitHub: on the repository's Security tab, choose Report a vulnerability (direct link). The report, the discussion and the fix stay private until an advisory is published. There is no security email address.

Please include the Playkeeper version (playkeeper version), what an attacker needs (network position, credentials) and what they gain, and steps to reproduce. Do not put working exploits, secrets, world backups, player identifiers or server addresses in public issues, pull requests or discussions.

Supported versions: only the latest release receives security fixes, as a new release. The one-line installer always installs the latest release, and from 0.2.0 on the dashboard offers it.

Design

Implementation constraints: authenticated HTTPS management, fail-closed authorization, local allowlisted privilege boundary, no public Docker socket/host shell/RCON, redacted logs, bounded player-data retention, explicit restore preview and rollback, safe installation on previously unmodified hosts. These properties are covered by the unit tests and the end-to-end jobs in CI.

Exposure review (v0.1.0, 2026-09-24; updated for 0.4.0)

Network. The panel listens on 8443, and each Minecraft server on its own game port (25565 for the first, then the next free port from 25566), published by Docker, with voice chat's UDP port on a server that has it (see Voice chat). The panel itself is served only over TLS. In v0.1.0 plain HTTP on its port got 400 Client sent an HTTP request to an HTTPS server; from 0.4.0 it gets a 308 redirect to HTTPS (400 when its Host header is missing or not a valid host), except under /resource-packs/: players' games refuse the panel's self-signed certificate, so that path serves over plain HTTP, without a sign-in, only the resource packs a server offers every player, by their SHA-1 (/resource-packs/<sha1>.zip, GET and HEAD), answers 404 to everything else, and limits each address to 60 requests a minute and 8 open downloads. A pack's link uses https:// on the same port instead while the panel serves a publicly trusted certificate for exactly the link's host with at least a week left, as the agent checks at each server start; either way the game checks the download against the pack's SHA-1. RCON (25575) stays inside the container on a private bridge; the Docker API is only the local Unix socket; apart from the two listeners below, the agent has no TCP listener. Checked with nmap -p- from another host during a rehearsal install. From 0.4.0 the agent also listens on port 80 for the seconds Let's Encrypt checks a certificate for an own domain, at issue and renewal, and answers only GET and HEAD for its pending /.well-known/acme-challenge/ tokens (anything else gets 404 or 405). If the panel is moved off 8443, it still listens on 8443 for the names service's liveness check (below) and answers nothing else there. From 0.4.0 the panel's port also carries /mcp, which answers only to API tokens (see AI agents), and the connections of machines joined to this dashboard (see Other machines); a joined machine opens no port for its link. While a server sleeps, the agent answers on its game port instead (see Sleeping servers).

Shared map (from 0.4.0). Apart from signing in, first-time setup, the health checks (/healthz, and /api/health, which gives the version) and the dashboard's own files, the panel answers without a sign-in only through its public route group: the resource packs above, the names service's liveness check (see Free names), the friends' pack page (see Friends' pack page), invite links (see Invite links) and a server's shared map. /mcp answers only to API tokens (see AI agents), and joined machines connect with their keys (see Other machines). The map is at /map/<link code>, with the calls its page makes under /api/public/map/<link code>/, over HTTPS and to GET and HEAD only. The link code is 22 random letters and digits (about 131 bits) and is made new each time sharing is switched on, so switching it off and on again retires every earlier link; the agent compares codes in constant time. The agent answers only while the map is on and shared and its server runs with squaremap loaded, and then only with the server's name and icon, the drawn worlds and their tiles, and, while Show players on the shared map is on, the names and positions of the players online, whose faces come from the panel, so viewers never contact Mojang. The page itself is the same for every code. For everything else, from an unknown, old or malformed code to a stopped server or an agent that doesn't answer, its calls get one 404 that names no server, sent no sooner than 200 ms after the request. Each address (an IPv4 address or an IPv6 /64, taken from the connection, never from headers) may make 60 page requests and 600 map requests a minute, with 4 and 24 open at once; answers are no-store, and the request log shows only /map/… or /api/public/map/…, never the code or the viewer's address. squaremap's web server listens on port 25580 inside the server's container and isn't published, and containers on Playkeeper's private network can't reach each other, so only the host reaches it. Within Playkeeper only the agent asks it anything, and only for the worlds, the players and PNG tiles, within size limits, rebuilding the JSON from the fields the page uses, so squaremap's own page and scripts never reach a viewer.

World uploads (from 0.4.0). Only an admin of a server can upload a world into it, and only an admin of all servers can upload one for a new server, with the session's CSRF token like every other change. The panel streams the bytes to the agent, which checks every name, size and byte. An upload holds up to 16 .zip, .tar.gz or .tar archives, recognised by their content; open uploads together may announce no more than half the free disk space, less a reserve, and at most 64 GiB, and each file must arrive in order, from where the last piece stopped, and no longer than announced. The audit log records each file's name, size and SHA-256. The agent reads the archives without unpacking them and refuses links, sparse files, absolute paths, .., names that appear twice, more than 500,000 entries, an archive (or a file over 1 MB in it) that expands to more than 100 times its compressed size, and a level.dat that decodes to more than 16 MB. While it writes, it counts the bytes it actually unpacks against those limits and against the space the import may use, whatever an archive's headers say. Nothing is written until the admin confirms the preview. Then the chosen world goes into a new folder in the agent's staging area, as new files at clean paths inside it, each checked against what was read before, and is moved into the server's data folder; a world folder there that is a link stops the import, and the server's server.properties goes through internal/gamefiles (see below). From the old server only the seed, game mode, difficulty and hardcore setting are carried over, never RCON, ports, online mode, the whitelist or the other settings that control access. The dashboard doesn't copy the old server's plugins or mods, which would run on the server, or its operators and player lists.

Files tab. Only admins see or change a server's files, and every change needs the session's CSRF token: the files hold what the dashboard keeps from moderators and viewers (the RCON password in server.properties, plugins' tokens and database passwords, logs with players' addresses), and a changed or uploaded file, like a plugin's jar, runs code in the game. The agent keeps every path inside the server's data folder the way it handles its own files there (internal/gamefiles, see below): a path must be relative and without dot segments, every folder on the way must be a real folder, so a link there is refused wherever it leads, and a link or special file at the name is listed as what it is, never opened; moving or deleting one moves or deletes it, never what it leads to, and a zip download leaves them out. A move, an upload or a new file takes its name only if the name is still free when it is renamed into place (RENAME_NOREPLACE), so a file a plugin writes in between is kept, not replaced. The editor opens and saves at most 2 MB of UTF-8 text, and of two saves of the version it opened, the second is refused. An upload may announce no more than the space uploads may use, one server keeps at most 4 of the 8 unfinished uploads a machine allows, each file arrives in order from where the last piece stopped and no longer than announced, and it goes in place only as a regular file, over another only when asked. While the game runs its world folders can't change, nor any other folder at the top with a level.dat, such as a plugin's world, so a world the game is writing is never overwritten half-way; and a change holds off the server's starts (a wake for a player who joins too), backups and restores until it is done, so none of them begins half-way through a long delete or move. A zip is counted before it starts and refused above 100,000 files and folders, since it keeps a record of each in memory until it ends, or with folders more than 64 deep, which only something planting them makes, and its names are made safe for any unpacker: a backslash, a colon, a trailing dot or a Windows device name in a name the game chose can't write outside the folder it is unpacked into. The panel types every download application/octet-stream, with Content-Security-Policy: sandbox and X-Content-Type-Options: nosniff, and names it itself with control characters, quotes, slashes and backslashes replaced, so a file from a server never renders as a page of the dashboard. The audit log records each change and download with its paths.

Privilege boundary.

  • playkeeper panel runs as the playkeeper user (not in the docker group) under a systemd sandbox that only allows writes to /var/lib/playkeeper/panel.
  • playkeeper agent runs as root with 5 capabilities (CAP_CHOWN, CAP_FOWNER, CAP_DAC_OVERRIDE, CAP_DAC_READ_SEARCH, and from 0.4.0 CAP_NET_BIND_SERVICE for Let's Encrypt's checks on port 80) and a read-only view of the host except /var/lib/playkeeper and /run/playkeeper. It serves a closed route table on /run/playkeeper/agent.sock (0660 root:playkeeper), checks the caller's UID with SO_PEERCRED, rejects unknown fields and trailing data, and never runs a shell. Console commands go to Minecraft over RCON as literal text. A backup made while players are online sends only save-off, save-all flush and save-on, and a command already written is never sent again.
  • A server's data folder is the game's: Minecraft and its plugins and mods can put anything there, including links to Playkeeper's own files. From 0.3.1 the agent reads and writes the files it manages there (server.properties, the allowlist and operators, the server icon, Paper's bStats setting and the jar it checks; from 0.4.0 also the ban list, the crash reports, Java's error reports and GC log that crash help and memory advice read, the headers of the world's region files it reads to count chunks, the mods folder whose jars size a mod loader's memory, and the folders it lists for them) only through internal/gamefiles. It keeps every path inside the folder (os.OpenRoot); refuses links, named pipes, sockets and devices, on the way to a file and at it; opens without waiting and checks the open file again; caps reads (1 MB for server.properties, the player lists, a crash report, Java's error report and each read of the GC log, 64 KB or less for the icon, 4 KB of each region file) and folder listings (1,000 to 20,000 entries, depending on the folder) and hashes a jar only up to 256 MB, stopping with its operation; writes a new temporary file and renames it over the old one; and gives the game's user only the folders and files it has just made, through their own handles. From 0.4.0 the one exception is the logs folder Java writes its GC log to: every start gives it to the game's user again, through a handle opened without following a link, and leaves a link or anything but a folder there alone. Where a refused file is needed, the action stops with a message naming it: a link where the bStats setting is written stops the start. Hard links are not checked: the game's container sees only its data folder, so it cannot link to anything outside it. From 0.4.0 the data packs in a world's datapacks folder, and Chunky's jar, settings and saved tasks for pre-generating the map, go through it too: a link on the way to them stops the action with a message naming it, and a linked data pack or jar is left out. So do the server's server.properties when a world is imported, where a link stops the import before it replaces anything, and the server icon on the shared map's page and for a sleeping server's stand-in.
  • From 0.4.0, when Docker refuses to start a server because its game port is taken, the agent asks Docker which running container publishes that port, through Docker's read-only list of containers, and names it unless Playkeeper made it; a name Docker wouldn't allow, or one over 64 characters, is left out. With no container on the port, it looks for the program listening on it: the socket in /proc/net/tcp and tcp6, then the process with that socket open. Without CAP_SYS_PTRACE the kernel only shows it the open files of root processes with no capabilities beyond its own, so most root services and other users' programs stay unnamed. It keeps only a printable name of at most 15 characters and the process id, and it never signals, stops or removes the program or the container.
  • Each Minecraft server has its own container, running as playkeeper-mc with all capabilities dropped, no-new-privileges, a memory limit equal to the chosen budget, a PID limit, only the game port published (and voice chat's UDP port on a server that has it), and no Docker socket mount.
  • Docker access is root-equivalent; that is why only the agent has it.
  • playkeeper-update.service (from 0.2.0) runs as root only when the agent leaves an update request in /var/lib/playkeeper/agent/update/, or an update it was installing was interrupted; the playkeeper-update.path unit watches for both. It runs the installed binary, not the downloaded one, and checks the staged release again with the installed version's keys. Its sandbox makes /usr, /boot, /efi and /etc read-only apart from /usr/local/bin, /etc/systemd/system and /etc/playkeeper, hides home directories and gives it a private /tmp; the rest of the host, /var included, stays writable to it.
  • From 0.4.0 schedules run in the agent, so they also run while the panel is down. A scheduled console command may only be say, save-all, weather, time set, time add, difficulty, gamerule, setidletimeout or kill @e[type=item], with its arguments checked, and goes to Minecraft over RCON once, audited like the Console with schedule:<id> as the actor.
  • A machine joined to another dashboard (from 0.4.0) runs no panel. Its agent runs as above, and its link to the dashboard runs as the playkeeper user in a systemd sandbox with no capabilities, reaching the agent only through its socket, which checks the link's user id as it checks the panel's (see Other machines).

Authentication. The first admin needs a one-time setup code printed by the installer (stored only as a SHA-256 hash, 24-hour expiry, deleted on use; setup is disabled once an admin exists). There is no open sign-up: every other account comes from a team invite (see Invite links). Passwords use argon2id. Session tokens are random, stored hashed, sent in a __Host- cookie with Secure; HttpOnly; SameSite=Strict, and expire after 12 hours idle or 7 days. Every state-changing request is refused from other sites: it needs a same-origin Origin or, without one, Sec-Fetch-Site absent or same-origin. One made with a session also needs the session's CSRF token; sign-in, setup, the second sign-in step and the invite page's calls, which have none, need a same-origin marker header instead, and /mcp takes only API tokens (see AI agents). Sign-in is rate-limited per address (10 attempts in 15 minutes, an IPv6 client by its /64) and per account (30 failures an hour from all addresses together), and 5 wrong passwords in a row lock the account out from that address for a minute, doubling up to 16 minutes; control actions are rate-limited per session. Responses carry a strict CSP and anti-framing headers.

Two-factor sign-in (from 0.4.0). Optional for the owner and required for every other admin, with an authenticator app: TOTP with HMAC-SHA1, 6 digits and 30-second steps, accepting one step either side; once a code is accepted, no code from its step or an earlier one works again. Turning it on needs the password, and the setup belongs to the session that started it and expires after 15 minutes; making new recovery codes and turning it off need the password and a current code or a recovery code. After a correct password the panel sets a separate __Host-playkeeper-2fa cookie for the second step, backed by a hashed row in its own pending_logins table, so it can never pass for a session; it allows 10 codes within 5 minutes, then the password is needed again. Wrong codes are counted inside a write transaction, so guesses sent in parallel all count: 5 in a row pause app codes for a minute, doubling up to 16 minutes, and 100 block them until a recovery code is used or root runs playkeeper reset-2fa <user>, so someone who knows the password gets at most 100 guesses. Each set has ten recovery codes of 80 random bits, stored only as SHA-256 hashes and used once each; they work while app codes are paused or blocked, and wrong ones are counted on their own and lock nothing, so they cannot be used to block anyone's app codes. After signing in, the dashboard tells each person about wrong codes entered for their account since their last sign-in, leaving out their own tries in that browser, and how many recovery codes are left when one was used or few remain. An admin other than the owner has Moderator rights until they turn it on, which the invite page offers right after they choose their password, and the owner or an admin of all their servers confirms that setup with one click; turning it off, or on again, needs a new confirmation. With Discord connected, every change to a team member's two-factor sign-in, and every admin confirmed by someone other than the owner, is posted there whichever alerts are chosen. API tokens for AI agents are separate and ask for no code (see AI agents).

Friends' pack page (from 0.4.0). The admin can switch on a public page for a modded server that shows friends what to install: the server's name and icon, its join address, its Minecraft and loader versions, its modpack, the mods friends need or may add, and a .mrpack file that holds only names, versions, file paths, hashes, sizes and Modrinth download links, never mods, configs or other files. Server-only mods are left out of both. The page is at /packs/<token>: the token is 22 random letters and digits (about 131 bits), never the server's name, made when sharing is switched on, replaced when it is switched off and on again, and compared in constant time. An unknown or old token, sharing switched off, a stopped server and a machine that can't answer all get the same 404, and the page itself is the same for every link. Its routes are in the panel's public group, limited per connection address (forwarded headers are ignored), no-store and noindex, never look at a sign-in, and appear in the log as /packs/…. The page loads nothing from Modrinth or CurseForge.

Certificates and HSTS (from 0.4.0). Without an address the panel keeps its self-signed certificate. With a free name or an own domain (Machine settings › Address), the agent gets a certificate for that one name from Let's Encrypt: through a DNS-01 check for a free name, whose TXT record the names service adds, and through an HTTP-01 check on port 80 for an own domain. The ACME account key is made on the host (/var/lib/playkeeper/agent/acme-account.key, 0600 root) and the account carries no email address unless root adds one as acmeEmail in /etc/playkeeper/config.json. Certificates and their keys are saved in /var/lib/playkeeper/certs/ (0640 root:playkeeper, so the panel can read them but not change them) and renewed when two thirds of their lifetime have passed (30 days before a 90-day certificate runs out). The panel chooses the certificate by the name the browser asks for: the name gets the Let's Encrypt certificate, while the IP address and any other name keep the self-signed one, so the dashboard stays reachable at the IP address if the name or its certificate breaks. HSTS lasts a day on names (max-age=86400), so a lapsed certificate locks a browser out of the self-signed fallback for at most a day after its last visit; on the IP address it is max-age=31536000, which browsers ignore for IP addresses. Sessions do not cross over: __Host- cookies belong to one host, so the name needs its own sign-in. An own domain's records are checked through public DNS-over-HTTPS, to see what Let's Encrypt and players see.

Free names (from 0.4.0). Free yourname.playkeeper.me addresses (yourname.playkeeper.io for installs from before the move) come from the names service at names.playkeeper.io, run by the Playkeeper project (code in cmd/playkeeper-names and internal/names/service; deployment, limits and operation in services/names). Each install makes its own Ed25519 key when it first needs one (/var/lib/playkeeper/agent/names.key, 0600 root). Every change is a request signed with that key, bound to the method, path, body, a timestamp within 5 minutes and a one-time nonce, and the key is the only proof of owning a name. The service points a name at the public address the claim or refresh came from, so a name can only point at the machine that asks, and adds an SRV record for each server on another port and, for a few minutes, the TXT records Let's Encrypt checks. One name per install; a released name is held for 30 days so nobody else takes over its players. The agent sends the name, its servers' short names and ports and the challenge values, and the service sees the machine's IP address; nothing about players, worlds or dashboard accounts. To check that a name still reaches its machine, the service asks https://<name>.playkeeper.me:8443/.well-known/playkeeper-names/<nonce> every 6 hours, and again when a lapsed name is refreshed or claimed. The panel serves that path without sign-in in its public route group, like the resource packs: at most 20 requests a minute and 2 at once from one address (the connection's, an IPv6 client by its /64), short deadlines, no caching, and one 404 for every failure (a malformed nonce gets 400); the agent answers only for the name this install holds or is claiming, with a signature of the name and nonce by the install's key. A name that hasn't answered for 7 days loses its records, and a name gets server addresses only 3 days after its claim and once it has answered. The service can change where every free name points, so a free name means trusting it and its operator, as with any DNS provider. It changes the playkeeper.me zone at Cloudflare with an API token for that zone alone, which can edit every record in it, because Cloudflare cannot limit a token to some records. The zone holds nothing but free names and the two records that keep anyone from sending email as the domain: the website, names.playkeeper.io and the /install redirect the one-line installer uses are in playkeeper.io, which the token cannot reach. The service's own code only touches the records it made under claimed names; the risk is the token leaking. It only works from the service's server, lives only in the hosting environment's variables (not in the image or the repository), never appears in logs, and Cloudflare's audit log lists every change made with it. An own domain or the IP address does not depend on the service, and the GitHub URL of the installer does not depend on playkeeper.io.

Invite links (from 0.4.0). An invite link's page (/join/<code>) and the calls it makes (/api/public/join/…) answer without a sign-in. Codes are 22 random letters and digits (about 131 bits), looked up by their SHA-256 hash; a friend link's code is also kept so the Players tab can copy it again, a team invite's is shown once. A friend link lets in a set number of people, up to 100 (the panel's API also allows no limit, which the dashboard doesn't offer), and runs out after a day, a week or a month, or only when it is turned off; a team invite lets in one person and runs out after a week. A turned-off link reads like an unknown one. The calls need the same-origin marker and count against the address (60 in 10 minutes) and, for failures that cost a Mojang lookup, against the link. Both paths are in the panel's public route group, which adds its own per-address limits (60 page loads and 120 calls a minute, 8 open at once), deadlines and Cache-Control: no-store, and logs only the path's prefix, never a code.

Discord (from 0.4.0). An admin of all servers can connect Discord in Settings › Discord by pasting a channel webhook's URL; there is no bot account and no OAuth. Playkeeper accepts only an https webhook URL on discord.com (or canary.discord.com, ptb.discord.com and the old discordapp.com), always sends to https://discord.com/api/v10 and follows no redirects. The URL's last part lets anyone who has it post in the channel, so the URL stays in the agent's database (0600 root): the browser only sees the webhook's name, and messages and logs never repeat it. The dashboard machine's agent does the posting, so alerts about crashes go out while the panel is down, and servers on joined machines post nothing. Every message sets Discord's allowed_mentions to none, so names from the game can't ping anyone. Messages name what they are about: servers with their state, join address and Minecraft version, a link to the server in the dashboard, and players, in the live status and in alerts about joins, leaves and join requests. Once Discord answers that a webhook is gone or refused, nothing more is sent to it until a new URL is saved. PLAYKEEPER_E2E_DISCORD_URL_UNSAFE, for the end-to-end tests, can point the agent at a fake webhook on the same machine only, and the agent logs a warning while it is set.

Data. Player IP addresses are never shown (log-ips=false plus redaction) and never stored, except while a join request from an invite page waits for an answer: the panel keeps the network it came from (the IPv4 address or the IPv6 /64) to limit requests, and clears it when the request is answered or the link is turned off. Player names, UUIDs and session times are kept for 180 days, samples for 30 days, 15-minute summaries of Java's memory log for 14 days and audit, which names the players an action concerned, for 365 days, with row caps. How a player got in (their name, UUID and invite) is kept while they stay on the allowlist, answered join requests until their server is removed, who woke a sleeping server for 90 days, and each player's face, under their name, with no time limit. To explain a crash, the agent reads the end of the container's log, the newest crash report or Java's error report, the names of plugin and mod files, the free disk space and the list of backups, and keeps what it found in memory; the log lines it shows are redacted like the console's. Secrets are generated on the host: an RCON password per server (/var/lib/playkeeper/agent/servers/<id>/rcon.secret, 0600 root, plus a read-only copy for the game user; a server from before 0.3.0 keeps /var/lib/playkeeper/agent/rcon.secret), TLS key (0600 panel user), session tokens, and from 0.4.0 the ACME account key, the names key, the Let's Encrypt certificates' keys, each account's two-factor secret and recovery-code hashes (see above), the machine link keys (see Other machines), the keys that encrypt copies of backups and the SSH key for SFTP copies (see Copies somewhere else) and the hashes of API tokens (see AI agents). Secrets pasted into the dashboard stay on the machine and are never sent back to the browser in full: a Discord webhook URL, a CurseForge key and the storage credentials for copies. Backups strip rcon.password and management-server-secret from server.properties and never include files holding the RCON password. Audit rows record actor, action, target and result, never secret values.

Outbound connections. Playkeeper itself contacts Docker Hub (pulling the runtime images, one per Java version, each pinned by digest), PaperMC (fill.papermc.io: the list of versions and builds with their checksums, fetched for onboarding, Settings, restores and template imports and reused for 30 minutes, and the Paper download in a setup-only container after the EULA is accepted), Mojang (piston-meta.mojang.com, for when each Minecraft version came out, also reused for 30 minutes) and GitHub (the latest release's playkeeper-release.json and .sig, a minute after the agent starts and then every 12 hours, and the release tarball only when the admin installs an update; the requests carry the User-Agent playkeeper-updater and nothing else about the server). From 0.4.0 the agent also contacts the add-on libraries Modrinth (api.modrinth.com, with files and icons from cdn.modrinth.com) and Hangar (hangar.papermc.io, with files and icons from hangarcdn.papermc.io), only for a server's Plugins or Mods tab and the add-ons it suggests, for modpacks and templates (see Server types, modpacks and templates), for the friends' pack page, for pre-generating the map, for turning on the Map and for AI agents' add-on tools: searches (results reused for 2 minutes), a project's details and versions, the files to install or update with the dependencies they need, each checked against the checksum its library publishes before it goes into the server's folder, Chunky the first time the map is pre-generated, squaremap when the Map is turned on, and, while the Plugins or Mods tab is open, a check at most every 15 minutes for newer versions, which sends Modrinth the SHA-512 of each file in the plugins or mods folder so it can recognise ones added by hand. Files and icons come only from those two CDN hosts, redirects included; the requests carry the User-Agent CIYAhq/playkeeper/<version> (https://github.com/CIYAhq/playkeeper), as Modrinth asks, and nothing else about the server, and browsers get icons through the panel, never from the libraries (the agent keeps them 24 hours). From 0.4.0, only while the admin sets up an address in Machine settings and while one is in use, the agent also contacts Let's Encrypt (acme-v02.api.letsencrypt.org) to get and renew the certificate; for a free name, the names service (names.playkeeper.io: whether the name being typed, or a few alternatives to a taken one, is free, then signed requests to claim it, refresh it daily, publish server addresses and challenge records, and check on records being published) and, before Let's Encrypt checks, the playkeeper.me nameservers for the challenge record; for an own domain, public DNS-over-HTTPS (cloudflare-dns.com, then dns.google) with the domain's names, every minute until the records are right and every 6 hours after. The panel shows each player's face from their own skin: for a player it hasn't seen recently, it asks Mojang for the skin (api.minecraftservices.com with the name, sessionserver.mojang.com with the UUID) and downloads it from textures.minecraft.net, sending nothing else, and keeps just the face in its database, looking again after 12 hours (6 for default skins or a name with no account, 10 minutes after a failed lookup). The same name lookup checks the names typed on invite pages. Browsers get faces from the panel, never from Mojang. The Paper server contacts Mojang (the matching vanilla jar from piston-data.mojang.com on its first start, Mojang services for keys and, in online mode, player authentication and allowlist name lookups) and PaperMC's version check when it starts. Playkeeper turns off two defaults that would send data elsewhere: Paper's bStats usage statistics (plugins/bStats/config.yml is written with enabled: false before every start, including after a restore) and the runtime image's download of default config files from a third-party GitHub repository (SKIP_DOWNLOAD_DEFAULTS). squaremap, which turning on the Map downloads, is set not to check for updates or fetch player heads, and the bStats statistics of its Paper build are off with the rest of bStats. From 0.4.0 the agent also contacts the other server types' download sites, CurseForge, the hosts modpacks name and the websites a template's data packs come from when they are used (see Server types, modpacks and templates), and, only once they are set up, Discord (discord.com, see Discord) and the storage that copies of backups go to (see Copies somewhere else). While Discord is connected, the agent fetches the version lists of the server types it runs a minute after it starts and then every 12 hours, to post about new Minecraft versions. A joined machine keeps one connection open to its dashboard (see Other machines).

Server types, modpacks and templates (from 0.4.0). Vanilla, Fabric, Quilt, NeoForge, Forge and Purpur servers get their software from their projects' own hosts (piston-meta.mojang.com and piston-data.mojang.com, meta.fabricmc.net and maven.fabricmc.net, meta.quiltmc.org and maven.quiltmc.org, maven.neoforged.net, maven.minecraftforge.net and files.minecraftforge.net, api.purpurmc.org), refuse redirects to any other host, check every file against a checksum its project publishes before it is used, and write it through a handle confined to the server's folder, so a link swapped into that folder cannot lead a write outside it. Purpur publishes only an MD5, from the same server as its jar. NeoForge's and Forge's installers, once checked, run in a setup-only container and download the libraries their profile lists themselves; Playkeeper then checks each against the SHA-1 inside the verified installer. Nobody publishes a hash for the Minecraft jar NeoForge's installer patches, so Playkeeper records its hash after the install and checks it before every start; Forge's must match the SHA-1 inside its installer. Modpacks come from Modrinth and, once a CurseForge API key is added in Settings › Add-on sources (kept in /var/lib/playkeeper/agent/curseforge-key, root only) or the release carries one (the CURSEFORGE_API_KEY repository secret, built only into the release the Release check builds and a tag publishes), from CurseForge, with each file checked against the hash its source publishes. CurseForge is reached only at api.curseforge.com, with files only from edge.forgecdn.net and mediafilez.forgecdn.net; a Modrinth pack may also name files on github.com, raw.githubusercontent.com and gitlab.com, as its format allows, redirected at most to objects.githubusercontent.com or release-assets.githubusercontent.com. Every download is HTTPS to those hosts only, redirects included. A pack may put at most 20,000 files on the server (5,000 before 0.4.3), and each of them, like each add-on file, at most 512 MiB (256 MiB before 0.4.3). The key built into a release is readable by anyone who has the binary, so it tells CurseForge that a request comes from Playkeeper, not from which machine. Only an admin of all servers can add or remove a machine's own key through the panel (the dashboard offers it to the owner only), which replaces the built-in one; the dashboard shows only its last four characters, and an empty key file turns CurseForge off on that machine. A modpack may suggest gameplay settings, never the port, RCON, the allowlist, online mode, operator or function permission levels, or where the world is; the agent writes them into server.properties through internal/gamefiles, so a link planted there stops the install with a message naming the file and is never read or written through. A template holds names, versions, checksums, web addresses and settings for the new server, never files, code, worlds, players or secrets: its plugins and mods install through the add-on library, a modpack it names comes only from Modrinth, pinned by its SHA-512, and the data packs it names are downloaded before the new server's first start from whatever website the template names, but only over HTTPS to a public address on port 443. The address is checked after DNS resolution (loopback, private, link-local, CGNAT, cloud metadata, documentation and other reserved ranges are refused), never through a proxy, every redirect is held to the same rules, and a pack is kept only if it matches the template's checksum. A template's resource pack is left out. A template names no one: it carries only the day it was made, which is only shown, never trusted, since anyone can edit a file. A template's share link (https://playkeeper.io/t#…) carries it after #, which browsers don't send to playkeeper.io.

Voice chat (from 0.4.0). Installing Simple Voice Chat asks first, then publishes one UDP port from that server's container (24454, or the next port above it that no server or program on the machine uses) and writes it into the add-on's settings through a handle confined to the server's folder. Nothing else is published, RCON included. Docker's published ports bypass ufw, so the machine needs no firewall rule; a firewall at the hosting provider is left to the owner, which the dialog says. A template that lists it says so, with the port, before the server is created, and the new server gets the port at its first start. A modpack that brings it names the port in its preview, and the port opens only if that is accepted when the server is created. A backup records the port: restoring it keeps the port the server has, or gives it, or a new server, the backup's port or the next free one above it. Removing the add-on drops the port, so the container is made without it at the next start.

Sleeping servers (from 0.4.0). Sleep is off by default. When it is on and a server has had no players for its idle time, the agent stops the server and listens on its game port itself, on every address, like the port Docker publishes. This stand-in answers Minecraft's status ping with the server's name, icon, version and player limit, and reads a join attempt only as far as the player's name, with at most 64 connections, 8 from one address (an IPv6 client by its /64), 10 seconds each and small size limits on every packet. A banned name never wakes the server; with the allowlist off any other name does, and with it on only a name on the allowlist or an operator's. If server.properties or the ban list can't be read, the allowlist rule holds. Wakes happen at most once in 2 minutes and 6 times an hour, and after a failed start the agent waits 5 minutes, doubling up to an hour. The stand-in can't check who a player is, so a name is only a claim; once the server runs, it checks players as usual.

One-line installer. https://playkeeper.io/install is a redirect (HTTP 302) to https://github.com/CIYAhq/playkeeper/releases/latest/download/get.sh, served by the nginx container in site/; after each release the release workflow checks that it resolves to the new get.sh. Whoever controls that server or the domain's DNS controls where the redirect points, so the GitHub URL, which skips it, is the one to use if you would rather trust GitHub alone. get.sh refuses plain HTTP (redirects included) unless PLAYKEEPER_ALLOW_HTTP=1 is set for a local test mirror, refuses a .sha256 over 1 MB or a tarball over 200 MB, and runs nothing from the download until the tarball matches its published .sha256, which must name that tarball. Because the checksum comes from the same GitHub release as the tarball, this catches corrupted, truncated or wrong files, not a compromised repository or release. To guard against that, read the script before running it (curl -fsSL https://playkeeper.io/install | less) and compare the tarball's SHA-256 with one you obtained another way, or use the tarball steps in the README.

The RHEL family and Amazon Linux. AlmaLinux, Rocky Linux, Oracle Linux, RHEL and CentOS Stream ship Podman rather than Docker, so a missing Docker comes from Docker's own repository (download.docker.com, its CentOS build, or RHEL's on RHEL, for the system's major version). The installer adds it as /etc/yum.repos.d/playkeeper-docker-ce.repo with gpgcheck=1 and the repository's signing key (Docker Release (CE rpm), 060A 61C5 1B55 8A7F 742B 77AA C52F EB6B 621E 9F35) written from the installer itself to /etc/pki/rpm-gpg/playkeeper-docker-ce.gpg, so dnf installs only packages Docker signed and no key is downloaded; both files, and the key once dnf has imported it, go when Playkeeper removes Docker. Amazon Linux 2023 gets its own docker package. The installer won't put Docker next to podman-docker or an installed runc, which Docker's containerd.io would replace, or take a Docker socket that answers as Podman; it never lets dnf erase a package to make room, and removing Docker removes only the packages the install added that nothing else needs, which rpm checks first. With firewalld on, the installer opens its ports in the zone of the interface with the default route, saved and running, and records the ones it added; uninstall removes those, and the docker zone and docker-forwarding policy Docker adds to firewalld when it first starts. SELinux stays as it is: systemd runs Playkeeper's services, like any program in /usr/local/bin, as unconfined system services, and when Docker labels containers for SELinux (selinux-enabled) each server's two bind mounts get :z, so Docker relabels only that server's data folder and its RCON password file for containers.

Updates (from 0.2.0). Every release carries playkeeper-release.json: its version, date and notes, and for each tarball (x86_64 and 64-bit ARM) its size and SHA-256 and the SHA-256 of the playkeeper binary inside it. An installed Playkeeper takes the entry for its own CPU and ignores the others. The release workflow signs it with Ed25519 (playkeeper-release.json.sig) using the private key in the PLAYKEEPER_RELEASE_SIGNING_KEY repository secret, and stops unless that key's public half is in internal/update/release.pub, which is compiled into every build. An installed Playkeeper installs a release only if its manifest verifies against the keys compiled into the installed version, it is newer than the installed version, and the download matches the manifest; the binary is taken from the tarball by name and checked against its own SHA-256. The agent checks all of this before it hands the release to the updater, and the updater checks it again before installing. Nothing from a download runs before these checks pass. Unlike the one-line installer's checksum, the signature also protects against a changed release or download location: a forged update needs the signing key. The updater keeps a copy of the replaced version, including its databases (which hold the accounts' password hashes and, from 0.4.0, their two-factor secrets and the other secrets listed under Data), in /var/lib/playkeeper/agent/update/previous/ (root only) until the next update. The one-time upgrade from 0.1.0, which has no updater, goes through the one-line installer and its checksum.

Copies somewhere else (from 0.4.0). Backups leave the server only when someone who may hold backup keys turns copies on, to S3-compatible storage over HTTPS or to another machine over SFTP, and only encrypted on the server with age (X25519) to the server's own keys. The S3 secret key, the SFTP password or the SSH key Playkeeper made, and the keys that open the copies stay in the agent's database (0600 root); the browser only learns whether a secret is set. Copies never connect to link-local, multicast, unspecified or cloud metadata addresses, and an SFTP machine's host key is confirmed by its fingerprint once; if it changes, copies stop until someone who may hold backup keys looks at it. Host keys must be Ed25519, ECDSA or RSA of at least 2048 bits, and the SSH key Playkeeper makes is Ed25519, with a line for the other machine's authorized_keys that starts with restrict, which turns off forwarding and terminals. Only the owner, or an admin whose two-factor sign-in is on and confirmed, may change where copies go, delete copies, and see or download the recovery key; one function, mayHoldBackupKeys in internal/panel, decides all of these. Restoring on a new machine from a recovery key also needs every server, as creating a server does. Recovery key responses are sent with Cache-Control: no-store, and each download, and each refused request, is in the audit log. The recovery key file holds the server's private keys and the folder its copies go to, never the storage's credentials, and is also an age identity file, so the age tool opens copies with it. A new key encrypts new copies only, and the old keys are kept, so an old file still opens older copies: if a file was lost or seen by someone else, make a new key, copy the backups again and delete the older copies. Restoring on a new machine reads the copies with the key file the user picks and saves neither the key nor the storage details.

Other machines (from 0.4.0). An admin of all servers can connect more machines to a dashboard in Settings › Machines. The dashboard makes a join code (XXXX-XXXX, 40 random bits, working once for 30 minutes) and a command with its address, the code and its key's fingerprint (128 bits of SHA-256). It keeps only a hash of the code keyed with its private key, and after 5 wrong codes from one address (an IPv6 client by its /64), or 20 from everyone, in 15 minutes, it pauses joining. Each side makes its own Ed25519 key: the dashboard's is /var/lib/playkeeper/panel/link.key, a joined machine's /var/lib/playkeeper/link/machine.key, both 0600 in a folder only their user can open, and refused if other users can read or change them. The machine dials the dashboard's panel port with TLS 1.3 and the protocol name playkeeper-link/1, and checks the dashboard's key against the fingerprint before it sends anything, the code included; after that, each side accepts only the other's key, and certificate names and dates play no part. Over that connection the dashboard sends the machine's agent the same requests it sends its own, as HTTP/2: only routes in the agent's route table, with clean paths, checked on both sides, with the account, token or schedule that asked in a header the agent audits. The machine only answers: it can't ask the dashboard or other machines anything, and it opens no port for the link. Answers that aren't streams (logs, backup downloads, uploads) stop at 16 MiB or after 60 seconds without progress. If two machines say they run the same server, the dashboard sends requests about it to neither. Removing a machine stops its key working at once, and its servers keep running there, out of the dashboard's reach; playkeeper leave disconnects a machine from its side. Updating a machine from the dashboard makes that machine install the latest release with its own checks and keys (see Updates).

AI agents (from 0.4.0). The panel serves Playkeeper's tools to AI agents at /mcp (the Model Context Protocol over HTTP). Only an API token in the Authorization: Bearer header counts there, never a cookie, and requests from web pages (with an Origin header) are refused, so a website can't use a signed-in browser to reach it. Each account makes its own tokens in Settings › AI agents: pk_mcp_ followed by 24 random bytes, shown once and stored only as a SHA-256 hash, with the rights of a Viewer, Moderator or Admin on every server or up to 100 of them, for 30, 60 (the default), 90 or 365 days, and at most 20 working tokens per account. A token never does more than its account may do now: a token for more is refused when it is made, and its rights are looked up again with its account's on every request and tool call. Removing an account from the team revokes all its tokens; lowering its role below the one it had when a token was made revokes that token, even one made for less; and a token for some servers is revoked once its account loses any of them, while one for every server follows the account's servers. A change on the Team page applies this at once and ends those tokens' sessions; otherwise the next check does, at the latest when the token is next used. Revoking a token ends its sessions at once, and resetting an account's password from the command line revokes all its tokens. The owner sees and can revoke everyone's tokens; others see their own.

A token can use 20 tools, each checked against its rights and servers. A Viewer token lists servers and reads their status, the console, who's online, the allowlist, backups, an operation's progress, lag reports and crash explanations, and searches Modrinth and Hangar for plugins and mods; a Moderator token also starts, stops and restarts servers, sends chat messages, edits the allowlist and makes backups; an Admin token also runs a console command (one printable line of up to 256 characters, on a running server), installs a plugin or mod from Modrinth or Hangar through the add-on library, with the same checks as the dashboard, and removes one that Playkeeper installed. No tool creates, deletes or restores servers, uploads worlds, changes settings, opens files or ports, or manages accounts, tokens, Discord, addresses or machines. Tools reach each machine's Playkeeper agent with the same allowlisted requests as the dashboard, and the audit log names the token as it names an account; calls a token's rights don't allow, or over its limits, are logged as refused. The endpoint allows 10 failed token checks a minute per address, 120 read-only and 30 other tool calls a minute per token and 256 KiB per message, keeps at most 16 sessions per token, and never logs arguments. Console lines, chat and player names come from players, so AI agents are told to treat them as data, never as instructions.

sudo playkeeper mcp serves the same tools over stdin and stdout, for an AI agent that reaches the machine over SSH. It has no sign-in of its own: as root it may do everything to that machine's servers, as root could anyway, so a sudo rule for it makes that account an owner. It then takes no arguments and reads nothing from the environment but SUDO_USER, to name the account in the activity log.

Supply chain. The runtime images (one per Java version) are pinned by digest; every Paper jar is checked before its first run against the SHA-256 that PaperMC's Fill API publishes for the build (servers created by 0.1.0 keep the checksums pinned in that release), and the other server types, add-ons, modpacks and a template's data packs against the hashes their sources publish (see Server types, modpacks and templates); releases are signed as described above; contributor toolchains are pinned by checksum; CI actions are pinned by commit SHA. Nothing proprietary is shipped (see docs/THIRD_PARTY.md).

Scans (2026-09-26). govulncheck v1.8.0 (Go 1.27.1): 0 vulnerabilities affecting Playkeeper (one advisory for the unmaintained golang.org/x/crypto/openpgp, which is not imported). npm audit for the UI: 0 vulnerabilities.

Known limitations.

  • At the IP address the certificate is self-signed; users must compare the fingerprint printed by the installer on first visit. Only a name (a free one or an own domain, from 0.4.0) gets a publicly trusted certificate.
  • The two-factor secret is stored unencrypted in the panel database (/var/lib/playkeeper/panel/panel.db, readable by the panel user and root), since a key kept on the same host would not protect it: anyone who can read that file or a copy of it can make codes.
  • Free names rely on the names service and its operator, whose Cloudflare token can edit every free name in the playkeeper.me zone (see Free names above).
  • The RCON password is also present in the server's server.properties in its data folder (readable by the game user and root), because the Minecraft server reads it from there.
  • The Files tab shows admins every file in a server's folder, secrets included (the RCON password, plugins' tokens and passwords), and lets them upload plugins and mods, which run code on the server.
  • The Files tab knows a world by the server's level name or a level.dat in a folder at the top of the server's folder; a world a plugin keeps deeper, such as in a world container folder, can change while the game runs. On a file system that can't rename without replacing (RENAME_NOREPLACE), such as a network file system, a move or upload checks the name just before the rename instead, so a file a plugin writes in that instant can be replaced. Uploads of every server share the space uploads may use, so an admin of one server can use it up until their uploads finish or sit idle for an hour.
  • PLAYKEEPER_E2E_OFFLINE_MODE_UNSAFE=1 lets anyone join under any name. It exists only for automated protocol-bot tests; the installer never sets it and the UI shows a permanent red warning when it is on.
  • Backups never contain a server's software (the Paper or Purpur jar, or a mod loader and its libraries), so a restore downloads it again, and NeoForge and Forge run their installer again: restoring needs outbound HTTPS to that type's hosts and Mojang.
  • Installed versions trust the release keys compiled into them, and there is no online revocation. Replacing the key takes two releases: one that lists both the old and the new key in internal/update/release.pub (signed with the old key), then one signed with the new key that drops the old one. A leaked key stays usable against every installation that has not installed that second release.
  • The Paper versions offered depend on PaperMC's Fill API: Playkeeper only offers builds that list a SHA-256, but it trusts PaperMC for which build is stable and for the checksum itself. The other types' versions and checksums come from their projects in the same way; Purpur publishes only an MD5, from the same server as its jar, and nobody publishes a hash for the Minecraft jar NeoForge's installer patches, so Playkeeper trusts the one built at install.
  • Anyone with a shared map's link sees the whole drawn world, builds included, until sharing is switched off; hiding players hides only where people are. Until the machine's name has its own certificate, friends who open the link see their browser's warning about the panel's self-signed certificate, with no easy way to check its fingerprint.
  • Automatic restarts give up after three failures in 15 minutes (crashes or failed starts) and say why; a start the user asked for that fails is not retried until they press Start again. The count starts over when the agent restarts, for example after a reboot: a server that should be running is tried again, and if the cause is still there, Playkeeper gives up again and says why.
  • An API token works without a password or two-factor code, so anyone who has one can use it until it runs out or is revoked. An Admin token runs console commands, installs plugins and mods, which run code on the server, and removes those Playkeeper installed.
  • A dashboard controls the servers on every machine joined to it as it controls its own, so whoever takes over the dashboard can do to them whatever it can, though no more than each machine's agent allows.
  • The CurseForge key built into a release can be read from the binary by anyone. If CurseForge withdraws it, CurseForge modpacks stop working on machines without their own key.
  • Copies of backups open only with the server's keys: if the machine and the recovery key file are both lost, the copies can't be restored. Anyone who has the file and can read the storage can open them.
  • Anyone who knows the name of a player on a sleeping server's allowlist, or of one of its operators, can wake it, up to 6 times an hour; with the allowlist off, anyone whose name isn't banned can.
  • Anyone who has the Discord webhook URL can post in its channel, and everyone who can read the channel sees what Playkeeper posts there: server addresses and, while the live status or those alerts are on, player names.

Can't find it?

Ask in GitHub Discussions. Answers are public, so the next person finds them too.

Ask in GitHub Discussions