Contributing

Build and run Playkeeper, run its checks and tests, find where things live in the code, and send a focused pull request.

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

Thanks for helping make self-hosted game servers easier. Outside contributions are welcome: bug reports, fixes, documentation, and features that fit the product brief. Contributions are accepted under the project's licence, AGPL-3.0 (see Licence and conduct).

First steps

  1. Read README.md and the product brief. The architecture and the stack decision explain how the pieces fit.
  2. Pick one concrete user-facing outcome. Open an issue or start a thread in GitHub Discussions before large architecture changes; ordinary fixes can go straight to a focused PR.
  3. Fork the repository, make the smallest coherent change on a branch, add tests for changed behavior, and run the checks below. If a check cannot run in your environment, say so in the PR rather than claiming a green build.
  4. In your PR, say what changed, how you tested it, what you didn't test, and include screenshots for UI changes. Do not include real worlds, player data, credentials, or public server addresses.

Found a security problem? Report it privately as SECURITY.md describes, not in an issue or pull request.

Set up and check (stock Ubuntu 24.04)

sudo apt-get update && sudo apt-get install -y git make curl ca-certificates xz-utils   # only if missing
git clone https://github.com/CIYAhq/playkeeper.git && cd playkeeper
./scripts/setup.sh     # pinned Go 1.27.1 + Node 24.21.0 into .tools/ (checksum-verified), npm ci
make check             # gofmt, go vet, ESLint (UI and browser tests), TypeScript, Go, web and installer-script unit tests

CI runs the same commands (.github/workflows/ci.yml: ./scripts/setup.sh, then make check split into jobs that run side by side: make lint typecheck and make lint-sh, make test-go-other, make test-agent, which runs the agent's tests in 16 shards side by side on one runner, since they mostly wait on timers, and checks that together they ran every test, and make test-web test-sh; a last job checks that they all passed). make package runs alongside them, and the end-to-end jobs install its tarball on fresh runners without waiting for the unit tests. One of them installs the packaged tarball, walks the keyboard-only onboarding, plays with two protocol bots, saves that played state to the Actions cache (scripts/e2e/played-state.sh) and uninstalls; a second runner, which starts beside it, restores its backup as soon as there is one. Another opens every page at desktop and phone sizes for accessibility and overflow (test/e2e/ui/views.spec.ts), and another runs the core flows at both sizes (sign in, create a server, see it start, back it up: test/e2e/ui/smoke.spec.ts). The views and the click-through start from the newest played state an ancestor commit saved (main's, or an earlier push of the same pull request's), with the build under test installed over it as an upgrade would, and walk the onboarding and the bots themselves when there's none, when the build doesn't come up on it, or when the change touches how that state is made (.github/actions/played-install). On pull requests and main, the click-through (below) crawls only the pages the change touches, and every page when it touches a layer they share. Another end-to-end job installs the current release, upgrades it to the commit under test with the one-line installer, updates it from the dashboard, and forces a failed update to check the rollback; its test releases are signed with a key made for that run (scripts/e2e/update-releases.sh). Other jobs get certificates from Pebble, Let's Encrypt's test certificate authority, build and check the names service image, and build the playkeeper.io image and walk its live demo. Pull requests from forks run the same CI; a maintainer may need to approve a first-time contributor's run.

Run it

  • make dev — agent and panel in one process with state in .dev/, using your Docker daemon. Run it as your normal user with access to /var/run/docker.sock (the docker group), not as root: the server container runs as the calling user. Open the printed https://localhost:8443/setup#code=… link. Without Docker the UI still runs and honestly reports Docker as unavailable.
  • cd web && npm run dev — the dashboard as you edit it, at https://localhost:5173, with /api passed on to make dev's panel. Run make dev first: the dev server serves HTTPS with its certificate (.dev/data/panel/tls/), so signing in works. Started before that certificate exists, it serves plain HTTP, and the panel refuses the sign-in.
  • cd web && npx vite --mode demo — the live demo (the dashboard with sample data from web/src/demo/, no panel behind it) at http://localhost:5173/demo/ while you edit; npm run build:demo builds it into web/dist-demo/.
  • The click-through presses every button, link, switch, tab, slider and menu item on every page at desktop and phone sizes, then again on the pages that change when a server is stopped, crashed or busy, when there's nothing to list, when an update is waiting, when the disk has space to free, when a server looks after itself (schedules, one of them paused, copies to another machine over SFTP, and sleep on) or has gone to sleep because nobody played, when plugins, packs and pre-generation are in use or paused, when friends, a team and Discord are set up, when the map is on or waits for a restart, when a server's folder has a few files of each kind (the Files tab's folders are crawled that way, as a fresh server's folder has dozens), at two-factor sign-in's second step and before setup (states laid over the panel's real answers in test/e2e/ui/fakes.ts). It fails with a list of the controls that do nothing visible, answer with an error or have no name, and also when it can't get back to a state it found, when a page has fewer controls than its minimum, or when it misses one of the places listed in clickthrough.spec.ts; for each of those places it breaks the control on purpose and checks that the crawl notices. A control that can't do anything right now must be disabled and say why. Changes go to fakes, so nothing on the server changes, apart from one AI agent token it makes so Settings › AI agents has one to open. A server's add-ons, a machine's modpacks and NeoForge's and Forge's version lists come from recordings (test/e2e/ui/fixtures/), so the crawl never waits on Modrinth, CurseForge or those two Maven servers. With make dev running and a server like CI's (a running Paper server with players and a backup): cd test/e2e/ui && npm ci && npx playwright install chromium, then PK_URL=https://localhost:8443 PK_PASSWORD=<your admin password> npx playwright test clickthrough.spec.ts --workers=2; it signs in as admin. CI runs it on fresh installs, which have no other machines connected, so the pages of connected machines are crawled only where one is; they start from a saved played state, and the Release check's walk the onboarding and the bots. CI splits it between runners with PK_SHARD=k/n (test/e2e/ui/clickthrough-plan.ts keeps each page with its faked states and balances the runners with the seconds each page took, clickthrough-costs.json; the gate job's clickthrough-gate artifact has fresh ones to commit when the runners drift apart), and a gate job (clickthrough-gate.spec.ts) fails unless the runners together crawled every page, pressed every control, and met every page's minimum, counted as one runner would, and every place and its negative control. On pull requests and main, node test/e2e/ui/plan.ts --base <commit> works out the pages a change touches: those whose modules under web/src/pages (listed in clickthrough-plan.ts, with what they import) changed, in every state they're crawled in, after / and the page above them as they are; every page when a change touches web/src outside pages/ (components, lib, i18n, the API client), how the dashboard is built, or the crawler and the state it crawls in; none for a change the dashboard doesn't draw, such as Go code, which the unit tests, every page's accessibility check and the core flows cover. PK_SELECTION='{"pages":["/servers/*/console"],"preludes":["/","/servers/*"]}' crawls some pages locally the same way. The full crawl, every page at both sizes, runs before each release as the Release check (below).
  • make package — release tarball in dist/ (static binary with the embedded UI, installer, notes), plus the other assets a release would carry: get.sh, the tarball under its stable name, and the release manifest playkeeper-release.json, which the release workflow signs.
  • make web, then in test/e2e/ui: npm ci, npx playwright install chromium and npx playwright test -c playwright.fake-panel.config.ts — browser checks against a faked panel API (fake-panel.ts), so they need no agent, Docker or Minecraft server: the Console with a long log arriving, and a backup file uploaded from the World tab and as a new server, at desktop and phone sizes. CI runs them too.
  • make e2e-vm — full rehearsal in fresh KVM guests (see scripts/e2e/vm-e2e.sh). It needs qemu-system-x86, qemu-utils, cloud-image-utils and Docker, sudo without a password, read and write access to /dev/kvm for your own user (qemu runs without sudo), and Playwright's Chromium (cd test/e2e/ui && npm ci && npx playwright install --with-deps chromium). It leaves a bridge, pkbr0, and iptables rules that forward and NAT 198.51.100.0/24 on your machine. The VM rehearsal workflow (.github/workflows/vm-rehearsal.yml) runs it on a GitHub-hosted runner when started by hand, before a release.
  • scripts/e2e/vm-os.sh OS RELEASES — the rehearsal on one supported system, such as debian-12 or rocky-10, in a fresh KVM guest from its official cloud image: preflight, the one-line install, onboarding, two protocol bots, a backup and a restore, an update from the dashboard, a reboot and the uninstall. On the RHEL family and Amazon Linux it also checks that a declined plan and an injected failure change nothing, that podman-docker is refused, that a port Docker publishes gets through firewalld and that SELinux denies nothing, and the uninstall there must leave the package sources, IP forwarding and firewalld as they were; OS_PREP can first turn on firewalld, run Podman, or install Docker Engine with SELinux labelling. RELEASES holds two builds made by scripts/e2e/update-releases.sh (key, then build a … and build b … with a higher version). It needs what make e2e-vm needs, plus the protocol bot (cd test/e2e/bot && npm ci). The OS matrix workflow (.github/workflows/os-matrix.yml) runs it on every release in internal/platform's list at once: on pull requests that change the installer or what it assumes about the system, by hand, and in the Release check before each release. Adding a release to the list means adding its cloud image to lab_os_url in scripts/e2e/vm-lab.sh and to the workflow's matrix; a release newer than the list already installs, with a warning. How each family installs is in internal/install/distro.go (where Docker comes from), packages.go (apt and dnf) and firewall.go (ufw and firewalld). LAB_ACCEL=tcg boots a guest without KVM, slowly, to check that it boots.
  • The ARM64 workflow (.github/workflows/arm64.yml) proves the arm64 build on GitHub's ARM runners (ubuntu-24.04-arm): make check and make package on ARM, every pinned Java runtime image pulled and run natively, and fresh ARM runners that install the arm64 tarball, walk the onboarding, play with the bots, back up, restore on a second runner installed with the one-line installer, start every server type with the hand-picked add-ons, the map, pre-generation and a modpack, and uninstall. It uses six ARM runners, so it runs on pull requests that change how Playkeeper is built, installed or updated or which runtime images it pins (for the packaging and release scripts, through the release workflow's dry run), when started by hand (Actions › ARM64), and in the Release check on the release it builds, which a tag needs to publish. Its server-type part, python3 test/e2e/software.py --url https://127.0.0.1:8443 --cacert cert.pem --code <setup code> --out <dir>, runs against any fresh install; it creates, checks and deletes a server of each type.
  • scripts/e2e/vm-release.sh fresh|owner — a release's two paths in a fresh KVM guest, with what make e2e-vm needs: a fresh install of the build, or the owner's path from the previous release (FROM, default v0.4.0) through the dashboard's updater; then sign-in and two-factor sign-in, Discord and free names with neither service in reach, every page, and errors in the logs. .github/workflows/vm-release-rehearsal.yml runs both on GitHub's runners; dispatch it before tagging.
  • ./scripts/negative-controls.sh — removes each safety guard in turn in a throwaway worktree and checks that the test covering it fails. It tests the last commit, so commit first.
  • go test -count=1 -run '^TestPebble$' ./internal/certs/ — gets certificates through HTTP-01 and DNS-01 checks from Pebble and pebble-challtestsrv, found in $PLAYKEEPER_PEBBLE_DIR or on $PATH; without them the test is skipped and prints how to install the pinned version (as the acme-pebble job in .github/workflows/ci.yml does).
  • scripts/names-check.sh and go test ./internal/names/... — build the names service image and check it, and test its client and service against a fake Cloudflare, without contacting Cloudflare. Deploying and running the service is described in services/names/README.md.
  • In make dev, Machine settings › Address talks to a names service at http://127.0.0.1:8081 and to Let's Encrypt's staging certificate authority, never the real names service or real certificates, unless "namesURL" or "acmeDirectoryURL" in .dev/config.json names others (make dev fills in the two when they are empty). With nothing on port 8081, the page says the names service can't be reached. To have one answer, run the names service image as scripts/names-check.sh runs it (port 8081, a dummy Cloudflare token, api.cloudflare.com pointed at the container): it answers whether names are free and refuses claims from a private address, so nothing reaches Cloudflare or Let's Encrypt.
  • scripts/site-check.sh — builds the playkeeper.io container from the repository root and checks every page, the /sizing guide, the live demo at /demo/, /t, /healthz and the /install redirect (needs Docker; CI runs it too, then walks the live demo in that image with test/e2e/ui/demo.spec.ts, and leaves it in Safari on an iPhone with demo-iphone.spec.ts, which needs npx playwright install webkit). In test/e2e/ui, npx playwright test -c playwright.site.config.ts opens every page of the site at desktop and phone sizes and fails on anything wider than the screen or a serious accessibility violation (CI runs it with the faked-panel checks). Building and hosting the site are described in site/README.md.
  • make notices — regenerates THIRD_PARTY_NOTICES, the licence texts of the third-party code in the binary. Run it after changing Go or npm dependencies and commit the result; make check fails while it is out of date, and while docs/THIRD_PARTY.md doesn't list each Go module and npm package in it at its version.

Protocol-bot tests need offline mode, which only the test harness enables (PLAYKEEPER_E2E_OFFLINE_MODE_UNSAFE=1 on the agent). Never set it on a real server. The harness sets it in /etc/systemd/system/playkeeper-agent.service.d/e2e-offline.conf. playkeeper uninstall keeps that file, even with --purge, because Playkeeper did not create it, so make e2e-vm removes it after uninstalling; if you add it by hand, remove it yourself or the next install on that machine starts in offline mode. Likewise, PLAYKEEPER_E2E_DISCORD_URL_UNSAFE sends the agent's Discord requests to a fake endpoint on a loopback address, for tests only; the agent refuses any other address and logs a warning while it is set.

Releases (maintainers)

Once, before the first signed release, make the release signing key and store it as the repository secret the release workflow signs with:

go run ./cmd/release-sign keygen | gh secret set PLAYKEEPER_RELEASE_SIGNING_KEY --repo CIYAhq/playkeeper
git add internal/update/release.pub && git commit -m "Add the release signing key" && git push

keygen adds the public key to internal/update/release.pub, which is compiled into every build, and writes the private key only to standard output, here straight into the secret. Keep an offline copy in a password manager if you want one; nothing else needs it. Installed versions only install updates signed with a key compiled into them, and the release workflow refuses to sign with a key missing from the released commit's release.pub, so every release can verify the next one. Replacing the key takes two releases; see the known limitations in SECURITY.md.

Optionally, CurseForge's API key. With a repository secret named CURSEFORGE_API_KEY, release builds carry CurseForge's API key, so owners get CurseForge modpacks without a key of their own; a key an owner adds under Settings › Add-on sources still wins. Request a key in the CurseForge for Studios console (console.curseforge.com), then run gh secret set CURSEFORGE_API_KEY --repo CIYAhq/playkeeper and paste it. Only the Release check passes it to make package, for the release it builds and checks and a tag then publishes; CI, pull requests and the release workflow's dry runs build without it, and scripts/package.sh never prints it (make test-sh checks the wiring with a dummy key). Anyone can pull a key out of a public binary, so first check that CurseForge's terms allow shipping one.

Each release: add a ## MAJOR.MINOR.PATCH section to CHANGELOG.md saying what changed (the dashboard shows it when it offers the update) and merge it. Wait for CI on that commit of main to pass, then run the Release check on it and wait for that to pass too: Actions › Release check › Run workflow on main, or gh workflow run release-check.yml --ref main. Without permission to start workflows by hand, as with an agent's token, push that commit unchanged to a branch named siya/release-check-<anything>: that runs the same check on exactly that commit. It builds the release exactly as it will be published, with the version from CHANGELOG.md's newest section (or the one you give it) and CurseForge's key built in, installs it on a fresh x86_64 runner and a fresh ARM one, and runs the full click-through on it, which pull requests run only for the pages they touch: every control on every page at desktop and phone sizes, split between runners, with the gate that proves together they pressed them all, and runs the ARM64 workflow on it on GitHub's ARM runners (.github/workflows/release-check.yml). It also runs the OS matrix: the install, play, backup, restore, update and uninstall on every supported release of Ubuntu, Debian, the RHEL family and Amazon Linux, which pull requests run only when they change the installer. Only then push a vMAJOR.MINOR.PATCH tag on that commit. That runs the release workflow, which publishes what the Release check built and checked, byte for byte, instead of building it again: it looks up a green CI run and a green Release check of the tag's version on the tagged commit, and without either it publishes nothing and says which is missing (run it on the commit, then re-run the workflow). It signs playkeeper-release.json with the release key and checks the assets again, a couple of minutes from tag to release. Then comes a normal (not pre-release) GitHub release with seven assets, get.sh, playkeeper-linux-amd64.tar.gz and playkeeper-linux-arm64.tar.gz with their .sha256, and playkeeper-release.json and its .sig, which becomes the latest release. Installed versions from 0.2.0 on offer it in the dashboard within 12 hours. The workflow then runs the one-line install from the GitHub release URL on a fresh x86_64 runner and an ARM one, and checks that https://playkeeper.io/install, a redirect to the latest release's get.sh, resolves to the new one; that last check only warns, because the site runs on its own server and needs no redeploy for a release. 0.x releases are labelled early. Once the release is out, set site/data/release.json to its version on main: playkeeper.io takes the release it describes, and says is out, only from that file, so the next version's section of CHANGELOG.md can collect lines as pull requests merge without the site announcing it early. That change can travel with the release's own commit, as long as it reaches main only after the tag has published. After each release, move the default version of builds that aren't releases in scripts/package.sh to the next version (0.6.0-dev once v0.4.0 is out), so it stays above both the latest release and the one after: a build named after a published release sorts below it, and CI's upgrade and "update available" checks then fail. For a dry run, start the release workflow by hand or open a pull request that touches the release path: it builds and checks everything, signs with a throwaway key, and uploads the assets as an artifact instead of releasing them. The site isn't part of the release: after changing site/, the sizing guide or the demo, redeploy it as site/README.md describes.

Where things live

Path What
cmd/playkeeper the single binary (install, agent, panel, machine link, MCP over SSH, recovery commands)
internal/agent root agent: socket API, Docker lifecycle, collector, backups/restore, schedules, sleep, off-site copies
internal/gamefiles reading and writing a server's files, which the game can change, as root: no links or named pipes, capped reads, refusals that name the file
internal/panel HTTPS panel: auth and two-factor sign-in, sessions, CSRF, rate limits, team roles, API proxy (to this machine and joined ones), public routes (resource packs, invite, map and pack pages), AI agent tokens and /mcp
internal/api, internal/agentclient, internal/config, internal/store, internal/version the JSON shapes the agent, panel and UI share (web/src/api/types.ts mirrors them; keep them in sync); talking to an agent over its socket or a joined machine's link; the host config; SQLite with append-only migrations; the build's version
internal/install preflight, installer with rollback, in-place upgrade, the updater, joining a machine, uninstall; apt or dnf, and ufw or firewalld, for each family of systems
internal/platform the supported releases of Ubuntu, Debian, the RHEL family and Amazon Linux, and how the installer treats the system it runs on
internal/update, cmd/release-sign signed release manifests (the release key is in internal/update/release.pub), version order, update downloads; the maintainer tool that makes and signs them
internal/backup, internal/backup/retention archive format; which backups the backup rules keep
internal/offsite encrypted copies of backups on S3-compatible storage or over SFTP
internal/minecraft, internal/minecraft/software, internal/docker Minecraft protocols, log parsing, PaperMC's version list and the Java for each version; downloads of the other server types; Docker client
internal/minecraft/software/forge.go Forge: its builds from Forge's Maven repository and its installer, each file checked against its published hash; tested by TestResolveForge and the three TestInstallForge tests in that package
internal/addons, internal/curated the plugin and mod library (Modrinth, Hangar); the hand-picked add-ons and voice chat's port
internal/modpacks, internal/templates modpacks from Modrinth and CurseForge, and the friends' pack page; server templates as files and links
internal/pregen, internal/packs, internal/webmap map pre-generation with Chunky; resource and data packs; the live map with squaremap
internal/worldimport, internal/nbt, internal/zipdir uploaded worlds; reading Minecraft's NBT files; checking zip archives
internal/portshare the panel's HTTPS and the resource packs' plain HTTP on one port
internal/diagnose how a server is running, what slows it down and why it crashed
internal/schedule, internal/sleep scheduled tasks; sleeping when nobody's playing and waking on a join
internal/diskusage what fills the disk and the ways to free space
internal/invites, internal/mojang invite links for friends and team members; Minecraft account lookups by name
internal/discord Discord alerts and the live status message
internal/certs the machine's certificates: Let's Encrypt (ACME) with HTTP-01 and DNS-01 checks, DNS checks of an own domain, join addresses and their records, renewal
internal/names, cmd/playkeeper-names, services/names the client for free playkeeper.me names and its signed requests; the names service, its image and how to run it (not part of the release)
internal/twofactor, internal/totp, internal/qrcode two-factor sign-in rules and recovery codes, authenticator codes, the setup QR code
internal/machinelink connecting other machines to one dashboard: join codes, the link each machine dials, and requests through it
internal/mcp, internal/mcptools the MCP server (JSON-RPC over HTTP and stdio) and Playkeeper's tools for AI agents
internal/sizing how big a VPS to rent for how many players and what they run, with the sources for each number; cmd/site builds the sizing guide at /sizing from it
web/, web/src/demo/ React + TypeScript UI (embedded at build time); the live demo: a make-believe panel in the browser and its sample data
packaging/ install.sh, the one-line installer get.sh, their tests, install notes
scripts/ toolchain setup, packaging, release checks, site and names checks, KVM rehearsal harness, negative controls
site/, internal/site, cmd/site playkeeper.io: its pages (the sizing guide and the template page among them), layouts and art, the generator that builds them with the docs from the repository's Markdown, and its nginx container with the live demo (hosted with Coolify)
test/e2e/ API client, scenario driver, protocol bot, Playwright specs
.github/workflows/ CI, the click-through it and the release path share, the ARM64 workflow, the Release check, the release workflow, the VM rehearsal, the VM release rehearsal and the OS matrix

Design principles

  • A first-time user should know what to do next without reading an infrastructure manual.
  • No simulated uptime/player analytics shown as real data. Label missing data and collection gaps.
  • Backups must be restorable; an on-host-only archive is not disaster recovery.
  • Keep the simple case simple: one machine and one server need no extra steps. Add integrations only when they solve an observed need.
  • The management plane must not expose a Docker socket, host shell, or unauthenticated/RCON service.
  • The installer must preflight and refuse collisions; do not take over existing Crafty or systemd-managed worlds.
  • Keep docs short, task-oriented, and tested against the released artifact. Prefer one clear happy path with honest recovery instructions over many speculative options.

Licence and conduct

Playkeeper is licensed under the GNU AGPL v3.0 only. By opening a pull request you agree that your contribution is licensed under the same terms; there is no separate contributor agreement. Only submit work you wrote or have the right to contribute under that licence, and keep existing copyright and licence notices intact.

Treat others respectfully; technical disagreement is welcome, harassment is not. A fuller governance policy can follow actual contributor demand, not precede it.

Can't find it?

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

Ask in GitHub Discussions