A module is a versioned, reusable game-server blueprint. Gameplane distributes modules as OCI artifacts so the same registries that hold your game-server container images also hold the templates that wire them up.
A module is one OCI artifact carrying a GameTemplate manifest, machine-
readable metadata, and optional README/icon. Admins point Gameplane at a
registry by creating a ModuleSource resource; users install a module by
creating (or clicking Install on) a Module resource, which the operator
materializes into an in-cluster GameTemplate.
Gameplane provides a unified toolkit for creating, verifying, previewing, and packaging modules:
- Command-Line Interface (
gp-module): First-class Go CLI with Makefile targets. - Web Dashboard Module Builder: Visual 3-step modal wizard available directly on
/modulesin the web dashboard.
Scaffold a complete, schema-compliant module directory with DNS-1123 validation using archetypes (steamcmd, java, or generic):
# Using make targets
make module-new NAME=cs2-match ARCHETYPE=steamcmd
# Or using gp-module binary directly
bin/gp-module init cs2-match \
--archetype=steamcmd \
--display-name="Counter-Strike 2 Match" \
--image="ghcr.io/gameplanepanel/cs2:latest@sha256:4b9a8e23f0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7" \
--ports="27015/udp" \
--categories="Shooter,Co-op"Run static offline linting and validation on metadata, CRD schemas, image digests, port ranges, and configuration types without requiring network or cluster access:
# Validate a single module
make module-validate MODULE=cs2-match
# Validate all modules with strict diagnostics
bin/gp-module validate modules/* --strict
# Machine-readable JSON output for CI pipelines
bin/gp-module validate modules/cs2-match --jsonSimulate server creation from template.yaml, evaluating dynamic memory limits (autoFromMemoryLimit) and environment variable precedence:
# Preview runtime config with an 8Gi memory limit
make module-preview MODULE=minecraft-java MEMORY=8Gi
# Simulate with custom user configuration
bin/gp-module preview modules/minecraft-java \
--memory=8Gi \
--config="MOTD=My Tournament Server" \
--jsonEnforce asset size limits (icon .tar.gz or push directly to an OCI registry:
# Package into a local tar.gz bundle
make module-package MODULE=cs2-match OUTPUT=dist/cs2-match.tar.gz
# Push OCI artifact to registry
bin/gp-module package modules/cs2-match \
--registry=ghcr.io/gameplanepanel/gameplane-modules \
--tag=1.0.0 \
--tag-latestIn the Gameplane web dashboard:
- Navigate to Modules in the main navigation.
- Click "Create module" in the top-right header actions.
- Follow the 3-step modal wizard:
- Step 1 (Preset & Metadata): Choose an archetype card (
SteamCMD Dedicated,Java Server, orGeneric Container), enter the module name (validated live against DNS-1123 standards), and select category chips. - Step 2 (Container & Ports): Configure container image (with digest pinning verification), dynamic port mappings (TCP/UDP, advertise flag), and storage volume size and mount path.
- Step 3 (Review & Export): View generated files across tabs (
module.yaml,template.yaml,README.md) with live syntax editing, monitor instant offline validation results, simulate memory limit scaling, and either download the.tar.gzarchive or install directly into your cluster's upload source.
- Step 1 (Preset & Metadata): Choose an archetype card (
The official modules live in the standalone GameplanePanel/module repo, which
the main repo checks out as the modules/ submodule. Whether you work in that
repo directly or through the submodule, a module lives on disk as a directory:
modules/<name>/
├── template.yaml # GameTemplate spec (no metadata.name)
├── module.yaml # Module metadata (see schema below)
├── README.md # rendered in the catalog detail drawer
└── icon.png # optional, 256×256 recommended
modules/build.sh and make modules-push are unchanged by the split — the
submodule mounts the GameplanePanel/module repo root at modules/, so the paths
below still resolve.
Each module directory should include a specs.md documenting the game and its
Gameplane integration. This is the canonical reference for operators authoring or
troubleshooting servers: console protocol details (message framing, authentication,
command syntax), available console modes (RCON protocol/port, telnet, WebSocket,
stdin pod-attach), configuration schema fields and their semantics, and
game-specific implementation notes (workarounds, quirks, gotchas the template
handles). Follow the structure documented in
../specs/done_011-add-missing-module-specs/contracts/specs-md-structure.md. Enforcement
(validation that specs.md exists and conforms to the structure) lives in the
GameplanePanel/module repo's own CI, not in this repo's make check-specs.
Both YAML files carry a first-line # yaml-language-server: $schema=… modeline
pointing at JSON Schemas under modules/.schema/, so editors (VS Code's
redhat.vscode-yaml) validate and autocomplete them instead of guessing an
unrelated schema. module.schema.json is hand-maintained; gametemplate.schema.json
is generated from the GameTemplate CRD — after changing CRD spec fields,
regenerate and commit it (in the submodule):
make module-schema # hack/gen-module-schema.py → modules/.schema/gametemplate.schema.jsontemplate.yaml is the same GameTemplate you would write today, including
metadata.name. gp-module validate requires metadata.name to be present
and non-empty, and gp-module init scaffolds it to match the module name.
apiVersion: gameplane.local/module/v1
name: minecraft-java # required, DNS-1123 label
displayName: Minecraft (Java Edition) # required
version: 1.0.0 # required, semver, must match the OCI tag
game: minecraft-java # required, free-form game family identifier
categories: [Sandbox, Survival] # optional, catalog groupings; a module may have several
summary: Vanilla / Paper / Forge / Fabric # required, one-line description for the card
homepage: https://minecraft.net # optional
license: MIT # optional, SPDX identifier
gameplaneMinVersion: 0.1.0 # optional, refuse install on older operators (example version)
icon: icon.png # optional, filename of the icon layerField rules:
-
nameis the canonical module identifier within a source. Two modules with the samenamein the sameModuleSourceare an error. -
versionis the canonical version string and must match the OCI tag the bundle is pushed under. -
categoriesgroups the module in the dashboard's Modules catalog. A module may belong to several at once — Minecraft is reasonably Sandbox, Survival, Building, Modded and Creative. The dashboard builds its filter chips from the distinct values across installed/available modules, so naming a new category here creates its filter button with no frontend change; a module appears under every chip it declares. Values are free-form, but the official modules stick to this canon:Survival · Sandbox · Shooter · Simulation · Building · Adventure · Horror · Co-op · PvP · Modded · Creative
Chips dedupe case-insensitively, so
Survivalandsurvivaldo not both appear. Whencategoriesis omitted, the dashboard falls back to a best-effort heuristic on thegameslug, and finally to "Other".The singular
category: Sandboxis still accepted for bundles authored before this field became a list; it is normalized to[Sandbox]on parse. New modules should usecategories. -
gameplaneMinVersionis checked at install time against the operator's build version; a module that needs a newer operator fails theModulereconcile with anIncompatibleOperatorcondition. The check is skipped fordev/unversioned operator builds.
A module bundle is a single OCI artifact:
| Layer | Required | Media type |
|---|---|---|
module.yaml |
yes | application/vnd.gameplane.module.metadata.v1+yaml |
template.yaml |
yes | application/vnd.gameplane.module.template.v1+yaml |
README.md |
no | application/vnd.gameplane.module.readme.v1+md |
icon.png |
no | image/png |
Manifest:
mediaType: application/vnd.oci.image.manifest.v1+jsonartifactType: application/vnd.gameplane.module.v1+jsonconfig: { mediaType: application/vnd.gameplane.module.config.v1+json, data: "e30=" }(the empty JSON object{}base64-encoded; we don't use the config for now but reserve it for future extensions)- Each layer carries an
org.opencontainers.image.titleannotation set to the filename (module.yaml,template.yaml, …) so the puller can identify layers by name.
Reference shape: <registry>/<repo>/<name>:<version>. Push every bundle
with both its semver tag and a moving latest tag if it's the default
channel.
The repo ships modules/build.sh which wraps oras and pushes a bundle
from a modules/<name>/ directory. Install oras (>= 1.2.0):
brew install oras # macOS
# or download from https://oras.land/docs/installationThen:
# push every bundle in modules/ to a registry
make modules-push REGISTRY=ghcr.io/gameplanepanel/gameplane-modules
# push a single module to a local kind registry
modules/build.sh push --registry localhost:5001 --name minecraft-javaUnder the hood build.sh runs:
oras push \
--artifact-type application/vnd.gameplane.module.v1+json \
ghcr.io/gameplanepanel/gameplane-modules/minecraft-java:1.0.0 \
module.yaml:application/vnd.gameplane.module.metadata.v1+yaml \
template.yaml:application/vnd.gameplane.module.template.v1+yaml \
README.md:application/vnd.gameplane.module.readme.v1+md \
icon.png:image/pngPrivate registries: log in once with oras login <registry>. The cluster
side uses a kubernetes.io/dockerconfigjson secret (referenced from
ModuleSource.spec.oci.pullSecretRef) — the same kind kubelet uses for
private images.
ModuleSource declares where modules come from. spec.type selects
the store; everything except OCI auto-discovers any directory holding
a module.yaml (use spec.allow to filter by name or glob). Sources
can be managed from the dashboard (admin) or applied as CRs:
apiVersion: gameplane.local/v1alpha1
kind: ModuleSource
metadata: { name: community }
spec:
type: git # oci | git | http | local | upload
git:
url: https://github.com/example/gameplane-modules
ref: main # branch or tag; the resolved commit is the digest
subPath: modules # optional scan root inside the repo
secretRef: { name: gh-creds } # optional, in the operator namespace
allow: ["minecraft-*"] # optional name filter (all types)
refreshInterval: 30mPer-type config:
| type | config | versioning | auth secret keys |
|---|---|---|---|
oci |
oci.url + explicit oci.modules list |
registry tags (semver) | dockerconfigjson via oci.pullSecretRef |
git |
git.url, ref, subPath |
one stream: module.yaml#version at the ref; digest = commit |
token or username+password (https); ssh-privatekey + known_hosts (ssh, both required) |
http |
http.url to a .tar.gz/.zip |
one stream; digest = content hash | token (Bearer) or username+password (Basic) |
local |
local.path under the operator's --module-local-root mount (Helm: operator.localModules) |
one stream; digest = content hash | — |
upload |
none — indexes uploaded bundles | one stream; digest = content hash | — |
Network safety. The operator fetches git/http sources through a
guard that refuses link-local, cloud-metadata (169.254.169.254),
unspecified, and multicast destinations — so a source can't be aimed at the
instance-metadata endpoint to steal the operator's credentials. Private and
loopback addresses are allowed, because self-hosted GitLab/Harbor and a
local kind registry legitimately live there.
type: upload sources index ConfigMaps in the operator namespace
labeled gameplane.local/module-upload: "true", each holding one bundle's
files under their canonical names. The dashboard's Upload module
flow creates these via POST /modules/sources/{name}/upload
(tar.gz/zip, ≤ 900 KiB), but a hand-applied ConfigMap indexes exactly
the same way:
apiVersion: v1
kind: ConfigMap
metadata:
name: module-upload-mygame
namespace: gameplane-system
labels: { gameplane.local/module-upload: "true" }
binaryData: # or stringData for plain YAML
module.yaml: <base64>
template.yaml: <base64>Once a ModuleSource is configured (Helm chart ships a default one
pointing at ghcr.io/gameplanepanel/gameplane-modules), modules show up in the
Modules page of the dashboard. Click Install to create a
Module resource:
apiVersion: gameplane.local/v1alpha1
kind: Module
metadata:
name: minecraft-java # becomes GameTemplate name
spec:
source:
name: default # ModuleSource name
name: minecraft-java
version: 1.0.0 # omit to track the source's latestThe operator pulls the artifact, parses module.yaml + template.yaml,
and creates a GameTemplate owned by this Module. Deleting the
Module deletes the GameTemplate. The operator refuses to delete the
module while any GameServer references the template; the UI surfaces
this as a clear "still in use by N servers" message.
If a Module fails to upgrade (pull error, bad signature, incompatible
operator), the previously-installed GameTemplate keeps running unchanged;
status.previousVersion records the last-known-good to roll back to by
re-pinning spec.version.
Two independent, opt-in controls harden the supply chain for OCI modules:
Digest pin (Module.spec.digest) refuses to install unless the resolved
bundle's digest matches exactly — defeating a tag that was moved to point at
new content. Set it to the OCI manifest digest shown in the catalog:
spec:
source: { name: default }
name: minecraft-java
version: 1.0.0
digest: sha256:abc123… # install fails (DigestMismatch) on any driftSignature verification (ModuleSource.spec.verify, OCI sources only)
requires every bundle from the source to carry a valid cosign
signature before it is installed. A bundle that is unsigned or signed by the
wrong key/identity fails the install with a SignatureInvalid condition.
Keyed:
apiVersion: gameplane.local/v1alpha1
kind: ModuleSource
metadata: { name: trusted }
spec:
type: oci
oci: { url: ghcr.io/gameplanepanel/gameplane-modules, modules: [{ name: minecraft-java }] }
verify:
key: { name: cosign-pub } # Secret in the operator namespace,
# public key under data "cosign.pub"Keyed verification checks the signature offline by default. Add
requireTransparencyLog: true to also require Sigstore (Rekor) inclusion
evidence, so a bundle only installs if its signing event was publicly logged —
which is what makes misuse of a stolen key detectable:
verify:
key: { name: cosign-pub }
requireTransparencyLog: true # needs outbound access to the sigstore
# trust root; leave off when air-gappedIt is off by default because a keyed signature is complete without a log entry, and requiring one would break air-gapped clusters and any private registry whose bundles are signed offline. The official bundles are logged, so enabling it against them works. The proof bundled with the signature is checked offline — there is no live log query per install. The setting has no effect on keyless verification, which always enforces the log.
Keyless (Fulcio certificate identity) — for sources whose author signs with a CI OIDC identity instead of a key. The operator needs outbound access to the sigstore trust root and Rekor:
verify:
keyless:
issuer: https://token.actions.githubusercontent.com
identity: https://github.com/<org>/<repo>/.github/workflows/release.yaml@refs/tags/v1.0.0The official Gameplane bundles are keyed-signed (see Signing official bundles below), so verify them with the keyed form above. Their signatures are also recorded in the public Rekor log, but the operator's keyed path checks the signature offline and does not require log inclusion — so this stays air-gap friendly.
spec.verify is rejected on non-OCI sources (cosign signatures are an OCI
concept); git/http/local/upload rely on the content digest plus a
Module.spec.digest pin instead.
The official modules/* bundles are signed by the release pipeline (ECDSA
P-256 key) and recorded in the public Sigstore Rekor transparency log. The
operator's keyed verify path is deliberately offline (no Fulcio/Rekor
connectivity required), which keeps air-gapped and self-hosted clusters
functional.
One-time key setup (maintainer). Generate a project signing key and store the halves in the right places — the private key never leaves CI secrets, the public key ships to users:
cosign generate-key-pair # writes cosign.key (private) + cosign.pub
# CI secrets (Settings → Environments → release-signing): paste each file
# COSIGN_PRIVATE_KEY = <cosign.key> COSIGN_PASSWORD = <the passphrase>
# Commit cosign.pub as signing/cosign.pub — CI drift-checks it against the
# private key on every publish, and it ships as a release asset.The release.yaml modules job runs modules/build.sh push --sign --tlog-upload on every v* tag, pushing each bundle to
ghcr.io/<owner>/gameplane-modules and signing it by digest with public Rekor
log recording. The default (no --tlog-upload) keeps signing local/offline for
air-gapped authoring. Signing is mandatory: a missing COSIGN_PRIVATE_KEY
fails the job rather than publishing unsigned bundles.
Verifying the official source (operator). Signing is an OCI concept, so
switch the default source to type: oci and turn verification on. The official
module-signing public key already ships in the chart's
defaultModuleSource.oci.verify.cosignPublicKey, so you only flip enabled:
# values.yaml
defaultModuleSource:
type: oci
oci:
verify:
enabled: true
# cosignPublicKey ships with the chart (the official ECDSA P-256 key,
# same as signing/cosign.pub);
# override it only to pin a different signer.The chart writes the key to a gameplane-module-cosign-pub Secret in the
operator namespace and sets spec.verify.key on the default source.
Signing your own bundles. build.sh --sign works for any registry; set
COSIGN_PRIVATE_KEY (and COSIGN_PASSWORD if the key is encrypted) and point a
ModuleSource.spec.verify.key Secret at the matching public key:
export COSIGN_PRIVATE_KEY="$(cat cosign.key)" COSIGN_PASSWORD=…
modules/build.sh push --registry ghcr.io/you/your-modules --signThe CRD types in operator/api/v1alpha1/gametemplate_types.go are the
source of truth; this section documents every author-facing block. The
official modules are the canonical examples: minecraft-java
(env-versioned catalog, per-loader mod volumes, three registries,
env-mode modpacks), valheim (channel catalog, extract-mode BepInEx
mods, deps-mode Thunderstore packs), terraria (tag catalog, pty
console, configFiles), factorio (tag catalog, pty console,
wizard-managed server-settings.json, portal registry), palworld
(wrapper channels, REST admin API console, pak mods). The full
catalog also includes 7-days-to-die, ark-survival-ascended,
ark-survival-evolved, arma-reforger, beammp, cs2, dayz, dont-starve-together,
enshrouded, euro-truck-simulator-2, farming-simulator-25, fivem, garrys-mod,
left-4-dead-2, mount-and-blade-2-bannerlord, nuclear-option,
project-zomboid, rust, satisfactory, squad, team-fortress-2, the-isle,
tmodloader, and v-rising (see Game Coverage for full protocol and testing status).
spec:
icon: icon.png # bundle file or URL/data-URI shown in the catalog
categories: [Sandbox] # optional, catalog groupings (mirrors module.yaml)
accentColor: "#5b9a3e" # CSS hex; tints this game's icon + accentscategories mirrors the module.yaml field and drives the Create-Server
template picker's category chips. Optional; the same fallback and legacy-scalar
handling apply. The two lists should agree — module.yaml feeds the Modules
catalog, template.yaml feeds the Create-Server picker, and they are read by
different code paths.
accentColor is how a module carries its own brand color into the
dashboard — the app no longer hardcodes a per-game palette, so a new
game shows the right color without any frontend change. Omit it for a
neutral default.
spec.versions declares the selectable game versions/flavors. The "New
server" wizard shows the catalog as a picker, and an existing server can
switch entries later from Settings → Version (the operator re-renders
the StatefulSet and restarts the pod on the new entry).
# Digests elided for readability — both refs below must actually be pinned
# as repo:tag@sha256:…; see §Pinning images by digest.
image: itzg/minecraft-server:java21 # fallback when no version is selected
versions:
- id: "1.21.4-paper" # what GameServer.spec.version stores (≤ 40 chars)
displayName: "1.21.4 · Paper"
image: itzg/minecraft-server:java21 # full image ref for this entry
loader: paper # keys into capabilities.mods.loaders (below)
gameVersion: "1.21.4" # upstream token passed to mod registries
default: true # pre-selected in the wizard (one entry)
env: # appended when selected
- { name: TYPE, value: PAPER }
- { name: VERSION, value: "1.21.4" }How authors express "a version" is up to the image (all three styles are used by the official modules):
- env-versioned — one image, per-entry
envselects the software (minecraft-java: itzgTYPE/VERSION; the tag only pins the Java runtime); - tag-versioned — the image tag is the game version (factorio:
stable/latest/pinned; terraria:tmodloader-*); - channel — entries select an upstream channel via env (valheim:
stablevspublic-test).
Semantics:
GameServer.spec.versionselects an entry byid; empty falls back to thedefault: trueentry (else the first). An id that matches no entry fails the server loudly (phaseFailed) instead of silently using the template image.GameServer.spec.imageoverrides whatever image the entry resolves to.- Env precedence: template
env< the entry'senv< schema-resolved config < GameServerspec.env. - Each (version + loader) combination gets its own mod volume — switching entries mounts that combo's PVC and leaves the others intact, so a Paper plugin set survives a detour through Fabric. See §Mods.
- At most 64 entries;
loadermust match a key ofcapabilities.mods.loaders(or be absent/vanilla-style for entries with no mod manager). Use the registry's loader token verbatim (e.g. Modrinth'spaper/fabric/neoforge) so browse filtering works.
Every image a user can get without choosing must be pinned to a digest.
validate.py enforces this, and CI fails a module that breaks it.
image: itzg/minecraft-server:java21@sha256:f71555873bdaa6… # required formWrite the readable repo:tag@sha256:… form rather than a bare
repo@sha256:…. Kubernetes resolves by the digest either way — the tag is
decorative and carries no guarantee — but it keeps the ref legible in the
dashboard and in kubectl describe, and it is what the refresh tooling
re-resolves against.
Why: a floating tag means a pod restart can swap the game binary under a
live world, with no version bump and no changelog. This is not hypothetical.
passivelemon/terraria-docker:terraria-latest moved from Terraria 1.4.4.9 to
1.4.5.6, changed the network protocol version, and broke the e2e Terraria bot
— the first anyone knew of it was a red CI job.
What the rule requires, exactly:
| Field | Requirement |
|---|---|
spec.image |
must be pinned (ERROR) — it runs when no version is selected |
a default: true version entry |
must be pinned (ERROR) — it runs when the user doesn't choose |
| any other version entry | pinned by default; may float if marked (WARN) |
A non-default entry may deliberately keep moving. Mark its image line and the pinner will leave it alone:
- id: latest
displayName: "Experimental channel"
image: factoriotools/factorio:latest # gameplane:floatingUse this only where tracking upstream is the entry's purpose and its
displayName makes the drift obvious — factorio's "Experimental channel",
terraria's "tModLoader latest preview". Selecting one is then the user's
explicit, labelled choice rather than a silent default.
Pinning is mechanical; don't hand-write digests:
make module-pin # rewrites every modules/*/template.yaml in placeIt resolves each tag through the registry's standard bearer-token flow (any OCI registry, not just Docker Hub), preserves the templates' comments, and leaves any ref it cannot resolve untouched rather than guessing. The digest it writes is the tag manifest's digest — for a multi-arch image that is the index digest covering every published platform, so a pinned module still runs on both amd64 and arm64.
Because pinning stops upstream releases from arriving on their own, the
GameplanePanel/module repo runs refresh-pins.yml monthly to re-resolve every
pin and open a PR. It never auto-merges: each changed digest is a game binary
that changes for every server on its next restart, which is exactly the event
worth a human's attention.
make module-pin re-resolves every module in the catalog and is meant for
that periodic refresh job, not for pinning the one module you're authoring.
To pin a single spec.image by hand, resolve the tag to a digest with
docker buildx imagetools inspect <image> or crane digest <image> and
append @sha256:<digest> to the image reference yourself.
Declared configSchema fields become inputs in the "New server"
wizard. At reconcile time the operator resolves the server's
spec.config against this schema and sets each resolved value as an
env var on the game container.
configSchema:
- name: MAX_PLAYERS
displayName: Max players
type: int
default: "16"
- name: DIFFICULTY
type: enum
enum: [easy, normal, hard]
default: normal
- name: SERVER_PASS
displayName: Password
type: password
- name: MAX_MEMORY
displayName: JVM max heap
type: string
default: ""
autoFromMemoryLimit:
percent: 75configSchema is not only used by the "New server" wizard: the server's
Settings > Game configuration section renders the same schema, so every
field stays editable after the server exists. Rules to keep in mind when
writing or revising a module:
- Saving a change always restarts the server (the operator re-renders the pod's environment and config files); there is no "apply on next restart" mode.
- Only values that differ from your
defaultare stored in the server'sspec.config; an unset key means "use the default" (or theautoFromMemoryLimitvalue), so changing a default in a later template version flows to servers that never overrode it. passwordvalues are write-only in the dashboard: the API never returns a stored password, and the form shows an empty "Unchanged" input.min/max(int) andminLength/maxLength(string, password) are checked in the form for early feedback; the operator re-checks them and is authoritative.- Removing or renaming a field orphans it. A server that stored a value
for the old name keeps that key in
spec.config, and the operator rejects unknown keys: the server reportsinvalid config: unknown config keys ...and does not roll out changes until the key is removed. The Game configuration section lists such keys under "No longer in the template" with a Remove button. Prefer adding a new field and keeping old ones for a release, or document the rename in your module's upgrade notes.
When a field's value is not supplied by the user or a default, autoFromMemoryLimit
computes it from the game container's effective memory limit:
floor(limit × percent / 100), rendered as whole mebibytes with an "M" suffix
This is useful for game memory settings (JVM heap, engine config) that should scale with whatever resources the server was given:
configSchema:
- name: MAX_MEMORY
displayName: JVM max heap
description: Leave empty to size it automatically to 75% of the container memory limit.
type: string
autoFromMemoryLimit:
percent: 75
- name: INIT_MEMORY
displayName: JVM initial heap
type: string
autoFromMemoryLimit:
percent: 75With limits.memory: 4Gi and percent: 75, both fields compute to 3072M
(75% of 4096 MiB). Resizing the server's memory updates the computed values
on the next restart.
Rules:
autoFromMemoryLimitis used only when neitherspec.config[field]nordefaultprovides a value — explicit values always win.- When the template has no memory limit, the field is left unset (no default applied).
- The rendered value is a whole number of mebibytes with an
Msuffix (e.g.,3072M).
Real-world example: Minecraft (Java Edition) sizes the JVM heap to avoid OOM kills. The heap should leave headroom for non-heap JVM memory (metadata, stacks, etc.), so 75% of the container limit is a safe heuristic:
resources:
limits:
memory: 4Gi # 4096 MiB
configSchema:
- name: MAX_MEMORY
displayName: JVM max heap
type: string
autoFromMemoryLimit:
percent: 75 # → 3072M| Field | Type | Min | Max | Notes |
|---|---|---|---|---|
percent |
int32 | 1 | 100 | percentage of memory limit |
Materialization rules:
- Defaults apply at reconcile time. A key absent from
spec.configresolves to itsdefault, sokubectl applyof a bare GameServer behaves exactly like the wizard (which pre-fills defaults). - Empty optional values are skipped — no env var is set at all,
letting the game image fall back to its own default. "Leave blank
for an open server" means unset, not
PASSWORD="". - Validation is strict. Unknown keys, enum violations,
unparseable
int/boolvalues, and emptyrequiredfields fail the GameServer (phaseFailed, message on theReadycondition) instead of materializing a pod that ignores user intent. Fixingspec.configrecovers automatically. passwordfields never appear inline in the pod spec. Values are stored in an owned<server>-configSecret and injected viaSecretKeyRef.- Precedence: template
env< schema-resolved config < GameServerspec.env— an explicit env override always wins. target: filefields feedconfigFilestemplates instead of becoming env vars — see the next section.
Games that read a config file instead of env vars (Terraria's
serverconfig.txt is the intended first adopter) declare
configFiles: a list of files the operator renders from the resolved
config values and places under storage.mountPath before the game
starts.
configSchema:
- name: MOTD
type: string
default: Welcome!
target: file # consumed by templates below, never an env var
- name: SERVER_PASS
type: password
target: file
configFiles:
- path: serverconfig.txt # relative to storage.mountPath
template: |
motd={{ .Values.MOTD }}
{{ if .Values.SERVER_PASS }}password={{ .Values.SERVER_PASS }}{{ end }}
world={{ .Server.Name }}template is a Go text/template
rendered with:
.Values— everyconfigSchemafield name mapped to its resolved value. Unset optional fields are present as"", so{{ if .Values.X }}guards work; referencing a name outside the schema fails the GameServer (missingkey=error). Env-target values are available too — a value may drive both an env var and a file..Server—.Nameand.Namespaceof the GameServer.
Rules and semantics:
- Paths are relative to
storage.mountPath. Absolute paths,..segments, unclean paths, and duplicates fail the GameServer. - Rendered files live in an owned
<server>-filesSecret — always a Secret, never a ConfigMap, because any template may embed a password value. - Files are copied onto the data volume by a
config-initcontainer on every pod start. The operator's rendering wins: manual edits to these paths (e.g. via the dashboard's Files tab) are overwritten on the next restart. Games may freely rewrite the files at runtime — the copy is plain data on the PVC, not a read-only mount. - The copy runs as root (busybox). Non-root game images that rewrite their config need permissions compatible with root-owned files.
- Changes roll the pod. Rendered contents are part of the config hash, so editing a template or a file-target value restarts the server with the new file.
- Failures are strict. A parse error, a missing key, or a
file-target value supplied while the template declares no
configFilesfails the GameServer (phaseFailed, message on theReadycondition) instead of silently dropping user intent.
The console protocol is selected via rcon.protocol. Ten protocols are
supported: source, telnet, websocket, battleye, satisfactory,
palworld, nuclearoption, rest, cli, and none. Each game's template declares the one(s) it uses.
Source-protocol games (Minecraft, CS2, other Valve engine titles):
rcon:
protocol: source
port: 25575
passwordEnv: RCON_PASSWORD # env the game image reads the password fromGames whose remote console is a raw line-based TCP/telnet session (7 Days to Die is the reference implementation: connect, send the password as a line, send a command as a line, read back whatever the server prints):
rcon:
protocol: telnet
port: 8081
passwordEnv: TELNET_PASSWORDThe agent's telnet client has no message framing to work with — it infers "reply complete" from a short quiet period on the socket rather than a length prefix or terminator, so a command that prints nothing at all still returns (empty output) rather than hanging.
Rust's Facepunch WebRcon protocol (requires +rcon.web 1 on the game server):
rcon:
protocol: websocket
port: 28016
passwordEnv: RCON_PASSWORDBattlEye RCon protocol used by DayZ and Arma-family engines (UDP, with checksum-framed packets and a mandatory client-side keepalive):
rcon:
protocol: battleye
port: 2305
passwordEnv: BATTLEYE_PASSWORDSatisfactory Dedicated Server's HTTPS function-call API (POST /api/v1
with a JSON "function" body, bearer-token auth after a PasswordLogin
call):
rcon:
protocol: satisfactory
port: 7777
# NOT env-injected: the admin claims the password in-game once, then writes
# it here (path is relative to the game's /config data mount).
passwordFile: gameplane/rcon-admin-passwordCaveat: Satisfactory's admin password cannot be injected by the operator
at startup — it must be set once in-game by the admin before the API
becomes available, which is why this is the one console that uses
passwordFile rather than passwordEnv. See the Satisfactory module's
README for the manual one-time claim procedure.
Palworld Dedicated Server's REST admin API (plain HTTP, not HTTPS; GET/POST
under /v1/api/..., HTTP Basic auth with username admin on every request).
Palworld's upstream deprecated its source RCON protocol in favor of this
REST API, which supports a real players list and preserves formatting in
announcements:
rcon:
protocol: palworld
port: 8212
passwordEnv: ADMIN_PASSWORDNuclearOption is a length-prefixed JSON request/response protocol on a dedicated pod-local remote-command port with no authentication:
rcon:
protocol: nuclearoption
port: 7779Generic HTTP/JSON administrative console APIs (e.g. FiveM txAdmin or Farming
Simulator 25 web admin). The agent issues HTTP POST requests with either
bearer token / X-TxAdmin-Token or HTTP Basic credentials, parsing output
from JSON envelopes or raw response bodies:
rcon:
protocol: rest
port: 40120 # txAdmin default port (or 8080 for FS25)
passwordEnv: TXADMIN_TOKEN # or passwordSecretRefThe agent automatically selects the appropriate adapter based on game identifier or port:
fivem(port 40120): txAdmin API (POST /fxserver/commands) withX-TxAdmin-TokenandAuthorization: Bearer.farming-simulator-25(port 8080): Web admin API (POST /api/console) with HTTP Basic auth.- Generic REST:
POST /api/commandwith JSON{"command": "<cmd>"}.
Console access over the container's stdin/PTY or local process execution.
Per the resolution recorded in OPEN-DECISIONS.md (Option A):
- The web dashboard console continues to attach directly via Kubernetes pod-attach / PTY (
consoleMode: pty). - The agent executes lifecycle stop sequences, health checks, and scheduled commands locally via standard input (Unix FIFO pipe or local process execution) without requiring remote TCP networking or an external password secret:
rcon:
protocol: cli
consoleMode: ptyFor games with no usable remote console, set protocol: none. See
consoleMode: pty below for stdin-driven games that accept console
commands over standard input.
rcon:
protocol: noneThe operator mints a password secret per GameServer, injects it into the
game container via passwordEnv, and mounts the same value for the agent
— the dashboard's console tab (and every RCON-backed capability) then
works automatically.
When the game image manages its own RCON password file (e.g., Factorio's
config/rconpw), use passwordFile instead of passwordEnv:
rcon:
protocol: source
port: 25575
passwordFile: config/rconpw # path relative to the game data mountIn this mode, the operator does not generate a Secret or inject any env var. The agent reads the password directly from the file on every connection.
The password source is chosen by precedence: passwordSecretRef (external
Secret) > passwordFile (game-managed) > operator-generated Secret (default).
| Mode | Use case | Secret injected | Env var | Example |
|---|---|---|---|---|
| operator-generated (default) | Operator controls the password | Yes (auto-created) | Via passwordEnv |
Minecraft with RCON_PASSWORD |
passwordSecretRef |
Use external credentials | Yes (external) | Via passwordEnv |
Any game with your own Secret |
passwordFile |
Game manages the password | No | No | Factorio with config/rconpw |
authentication (default true) says whether the console requires a password at
all. Set authentication: false for a console with no authentication, such as
Nuclear Option's loopback-only remote-command port: the operator then mints no
password Secret, mounts nothing for the agent, and injects no password env var.
It cannot be combined with passwordSecretRef, passwordEnv or passwordFile
(the API rejects the combination). Modules that relied on the old implicit
no-password behaviour for protocol: nuclearoption must now set it explicitly.
For games without usable RCON set protocol: none. Real cases from the
official modules:
- the game simply has none (Valheim, Terraria);
- the image manages its own RCON password in a file the operator can't
inject into — use
passwordFileto read from the data volume.
consoleMode: pty # attach to container stdin/stdout
logPath: /data/logs/latest.log # agent tails this for the "Game log" viewWith rcon.protocol: none, set consoleMode: pty if the server reads
commands on stdin (Terraria, Factorio) — the Console tab then attaches
via the kubelet's pod-attach API instead. Without either, the tab shows
"no live console" and RCON-backed capabilities (players, quiesce,
actions, status) are reported unsupported.
capabilities.lifecycle.stop is the one exception: it works for
consoleMode: pty games too, since the operator (not the agent) drives
it — it writes the declared commands to the game container's stdin over
its own pod attach, the same mechanism as the Console tab, rather than
going through the agent's RCON connection. The operator picks the
transport per template: RCON when rcon.protocol isn't none, stdin
pod-attach when it's none and consoleMode: pty, and — if neither
applies — no graceful stop at all, so the server scales down immediately
on suspend/restart/stop instead of waiting out
spec.stopGracePeriodSeconds for a sequence it has no way to run.
logPath is an absolute in-container path pointing the agent at the
game's own logfile. Omit it for games that log only to stdout; the Logs
tab's "Container output" source covers those.
Some game images refuse to run as root or require a specific uid. Use
spec.security to override the pod/container security settings:
security:
runAsUser: 25000 # uid the game container runs as
runAsGroup: 25000 # gid the game container runs as
fsGroup: 25000 # gid the kubelet chowns the data volume toAll three fields are optional. When set, the operator applies these
values to the game container's securityContext; fsGroup is set on
the pod spec so the kubelet chowns mounted volumes on startup, letting
a non-root game user write to the PVC without additional init
containers.
Real-world example: ARK: Survival Ascended's image (mschnitzer/asa-linux-server)
runs the server as uid 25000 and cannot initialise Proton (the Windows
compatibility layer) as root — it fails with "Parent directory does not
exist" and the server never reaches readiness. Setting runAsUser: 25000,
runAsGroup: 25000, and fsGroup: 25000 works around this:
spec:
security:
runAsUser: 25000
runAsGroup: 25000
fsGroup: 25000| Field | Type | Default | Min | Max | Notes |
|---|---|---|---|---|---|
runAsUser |
int64 | omitted (image default) | 0 | 4294967295 | uid the game container runs as |
runAsGroup |
int64 | omitted (image default) | 0 | 4294967295 | gid the game container runs as |
fsGroup |
int64 | omitted (no chown) | 0 | 4294967295 | gid the kubelet chowns volumes to |
The agent sidecar shares the pod's network namespace with the game
container, so any TCP port the agent itself binds is reserved: a game
template that also binds one of these loses (or wins) an arbitrary bind
race with the sidecar container start order. Do not declare a game port
of 8090 (the agent's mTLS control listener) or 9090 (the agent's
plain-HTTP Prometheus metrics listener, agent/cmd/main.go's
--metrics-addr) in spec.network.ports.
If a template's game genuinely needs one of these ports, pick a different port for the game and remap it at the game's own config layer where possible — the agent's ports are not configurable per template today.
spec.capabilities declares the console commands behind agent
features, so a module adds full Players-tab moderation and safe
backups without any agent code. All of these commands run over RCON,
so they require rcon.protocol ≠ none — except lifecycle.stop,
which the operator itself can also run over a consoleMode: pty pod
attach (see "Console mode and game log" above).
capabilities:
players:
kick: "kick {{.Player}}{{if .Reason}} {{.Reason}}{{end}}"
ban: "ban {{.Player}}{{if .Reason}} {{.Reason}}{{end}}"
unban: "pardon {{.Player}}"
banList:
command: "banlist players"
entryRegex: '^\s*(?P<name>\w+)\s+was banned by\s+(?P<source>[^:]+?)(?::\s*(?P<reason>.*))?\s*$'
whitelist: # optional allow-list management
list: "whitelist list"
add: "whitelist add {{.Player}}"
remove: "whitelist remove {{.Player}}"
listRegex: 'whitelisted player[s()]*:\s*(?P<names>.+)$'
quiesce:
quiesce: ["save-off", "save-all flush"] # run before a backup snapshot
unquiesce: ["save-on"] # run after
failurePattern: "saving failed" # output regex that fails the step
lifecycle:
stop: ["stop"] # graceful-stop console sequence-
Moderation commands are Go
text/templates rendered with.Playerand.Reason(reason may be empty — guard with{{if .Reason}}). The reason is validated with the same input policy as action string params (no control characters, no ; & | $ ` \ " '; max 256 bytes), so templates may interpolate it without extra escaping of those characters. Unset actions are reported as unsupported and the UI hides them. -
banList.entryRegexmatches one banned player per output line via the named groupsname(required),sourceandreason. -
whitelistadds allow-list management to the Players tab:add/removeare.Playertemplates andlistRegexextracts the comma-separated tail of thelistcommand's output via the named groupnames. -
listlets games with no moderation capability still report the online player list.commandis the console command that prints the players (e.g.,/players onlineon Factorio,ShowPlayerson Palworld).entryRegexoptionally extracts one player name per match (first capture group, or whole match if no group); multiline mode (^/$per line). When omitted, a built-in parser is used. This is useful for games that have no player-management RCON but do report online players — the Players tab then shows who is connected, without kick/ban/whitelist actions.From
modules/factorio/template.yaml:capabilities: players: list: command: "/players online" entryRegex: "^\\s+(.+?) \\(online\\)$"
From
modules/palworld/template.yaml(note: Palworld uses the REST admin APIprotocol: palworld, not RCON, so the players list is fetched via the REST endpoint, not a console command). -
The quiesce sequence runs in order; any command error — or output matching
failurePattern(case-insensitive) — aborts the backup. If at least one earlier command in the sequence already succeeded,unquiescethen runs best-effort so the game is never left paused; a failure on the first command means nothing was paused yet, sounquiesceis skipped. Games that can't quiesce simply omit the block; backups proceed without pausing. -
lifecycle.stopruns before the pod is scaled down (stop button, restarts): the operator issues the sequence — over RCON when the template has it, otherwise over a stdin pod-attach forconsoleMode: pty— and waits for the server to exit on its own, so a SIGTERM never interrupts an in-progress world save (["Save", "Shutdown 1"]on Palworld,["stop"]on Minecraft,["save", "exit"]for a stdin-driven game like Terraria). Omit it for games that save on SIGTERM, or for games with neither RCON norconsoleMode: pty— there, declaring it has no effect since the operator has no transport to run it over and scales straight down instead.
The agent has no per-game special-casing — every capability above (and the two below) comes from this block. A template that declares nothing reports those features unsupported and the UI hides them.
capabilities.actions declares named operator buttons on the server
detail page. Each runs a console command built from a Go text/template
rendered with the parameters the user fills in, sent over RCON.
capabilities:
actions:
- id: broadcast # stable, unique within the template
displayName: Broadcast message
description: Send a chat message to everyone. # shown in the dialog
icon: megaphone # optional lucide-react icon name
command: "say {{.Params.message}}"
params:
- name: message # referenced as {{.Params.message}}
displayName: Message
type: string # string | int | bool | enum
required: true
- id: set-weather
displayName: Set weather
command: "weather {{.Params.weather}}"
confirm: false # require a confirm step in the UI
danger: false # red styling for destructive actions
params:
- name: weather
type: enum
enum: ["clear", "rain", "thunder"]
default: clear
- id: save-world
displayName: Save world
command: "save-all" # no params is fine
- id: announce-restart
displayName: Announce restart
group: Server # buttons are grouped by this label
danger: true
commands: # a sequence, run in order
- 'say "Server restarting in 60s"'
- "save-all"
- "stop"commandvscommands— set exactly one.commandis a single console command;commandsis a sequence run in order (each a Go template rendered with the same.Params), mirroring howcapabilities.lifecycle.stopandcapabilities.quiesceexpress sequences. A mid-sequence failure aborts the remaining steps.group(optional) labels a section on the server detail page, so a game with many actions reads as World / Server / Players groups rather than one flat list. Ungrouped actions fall under a trailing "Actions" section; if no action declares a group the card stays a flat list.transport(optional,rcon|stdin) selects how the commands reach the game. Empty resolves torconwhenrcon.protocolisn'tnone, elsestdin. Stdin actions are written to the game container's stdin via pod-attach — this is what lets aconsoleMode: ptygame (Terraria, Don't Starve Together) carry actions at all. Stdin is fire-and-forget: no output is returned inline (it appears in the Console tab), so the dashboard shows "sent" rather than command output.- Parameter values are validated by
typeand sanitized before rendering: CR/LF, other control characters and the shell/RCON metacharacters; & | $ ` \ " 'are rejected so a value can never chain a second console command.int/bool/enumvalues must parse/match; missing optional params fall back todefault. The same validation runs on both transports and on both the API and the agent — the sharedgameactionmodule — so neither is trusted blind. - A command template that fails to parse disables only that one action (logged), never the whole panel.
currentFrom(optional, on a param) names acapabilities.status.metrics[].id. When the dialog opens, the dashboard pre-fills the param with that metric's live value, so a "set" action starts on the current setting. Anenumparam takes the option that matches case-insensitively (a reading ofEasyselectseasy), abooltakestrue/falseand aninttakes an integer. With no reading, or no valid match, the param falls back todefault; the user's own choice always wins. Readings come from the Status metrics below, so they need RCON. Minecraft'sset-difficultyusescurrentFrom: difficulty.
capabilities.status.metrics declares live readouts on the Overview
tab. Each runs an RCON command and extracts a value via a named-group
regex (the group must be value).
capabilities:
status:
metrics:
- id: seed
displayName: World seed
command: "seed"
regex: 'Seed: \[(?P<value>-?\d+)\]'
- id: difficulty
displayName: Difficulty
command: "difficulty"
regex: 'The difficulty is (?P<value>\w+)'
unit: "" # optional suffix, e.g. "ms", "TPS"Values are cached for a few seconds. A metric whose command errors or doesn't match this cycle is shown with an empty value rather than dropped, so the panel layout stays stable. When the game has no RCON the endpoint returns nothing and the dashboard omits the panel.
capabilities.mods declares how the dashboard manages this game's
mods/plugins. Most games store mods in a directory on the data volume;
the dashboard lists, installs, uploads, updates, and removes them
generically by calling the agent — no RCON required. Some games, however,
download their own mods by ID (a Steam Workshop id, a CurseForge project id,
etc.) passed via a command-line flag or environment variable; for those,
use idList instead. The three layouts are mutually exclusive by convention
— the schema requires at least one but does not reject a template that sets several:
File-based mods (the default):
# Per-loader map (use with spec.versions):
mods:
loaders:
paper: { displayName: Plugins (Paper), path: plugins, extensions: [".jar"] }
# Or a single shared directory:
mods:
path: mods
extensions: [".jar"]ID-based mods (games that download by id):
mods:
idList:
env: ASA_START_PARAMS # env var the id list is projected into
separator: "," # joins ids; default ","
format: " -mods={{ids}}" # token "{{ids}}" is replaced; default "{{ids}}"
mode: append # "replace" (default) or "append" this env
registry:
providers: [...] # optional; curated registry browsing
# Note: install/allowedHosts are omitted for idList games — the
# operator projects ids, the server downloads them itselfWhen to use each:
- File-based: the agent manages the mods directory, downloads files, and handles installs/removal. Games like Minecraft (Forge/Fabric plugins), Valheim (BepInEx), and Palworld (.pak files).
- ID-based: the game server downloads its own mods given a list of ids. The operator renders the id list into an env var or flag; the agent never downloads anything. The dashboard can still browse a curated registry (e.g., CurseForge for ARK) to help users find mods, but the install is just projecting ids, not file transfers. Useful when the game has no accessible mods directory but does accept id lists.
Real-world example: ARK: Survival Ascended's image downloads mods via
CurseForge ids appended to its launch parameters. The operator renders
the user's selected ids into the ASA_START_PARAMS env var (append
mode, so user-supplied params survive):
mods:
idList:
env: ASA_START_PARAMS
format: " -mods={{ids}}"
separator: ","
mode: append
registry:
providers:
- provider: curseforge| Field | Type | Default | Max | Notes |
|---|---|---|---|---|
env |
string | required | 64 chars | env var the rendered id list is projected into |
separator |
string | , |
4 chars | string joining ids, e.g. , or ; |
format |
string | {{ids}} |
128 chars | plain string replacement of the literal token {{ids}} — not a Go template |
mode |
enum | replace |
— | replace (set env to format result) or append (concatenate onto existing env) |
File-based layouts support two patterns:
capabilities:
mods:
# Per-loader map (use with spec.versions): the active version's
# `loader` selects the directory, and every (version + loader) combo
# gets its OWN PVC — switching versions never clobbers another
# combo's mod set. Max 16 keys.
loaders:
paper: { displayName: Plugins (Paper), path: plugins, extensions: [".jar"] }
fabric: { displayName: Mods (Fabric), path: mods, extensions: [".jar"] }
bepinex:
path: bepinex/plugins
extensions: [".zip"]
extract: true # unpack archives into per-mod folders
install:
allowedHosts: # SSRF allowlist (required for URL installs)
- cdn.modrinth.com
- ".curseforge.com" # leading dot → host + subdomains
maxSizeMB: 512 # default 256, max 4096
registry: # optional in-app browse — see below
providers:
- provider: modrinthcapabilities:
mods:
path: mods # legacy single directory, relative to
extensions: [".jar"] # storage.mountPath — for games with
install: { ... } # no version/loader dimension- Listing/removal work with or without an
installblock. A version entry whoseloaderhas no map key (e.g. vanilla) gets no Mods tab at all. - URL install downloads a user-supplied URL into the active
directory. It is refused unless the URL's host matches
allowedHosts(exact host or a.suffixfor domain + subdomains) and the resolved address is publicly routable — the agent blocks loopback, private (RFC1918/ULA), link-local, and metadata addresses so it can't be tricked into fetching cluster-internal services. Downloads are size-capped and redirects are re-checked against the allowlist. - Upload (the install page's third mode) sends a local file to the
agent as multipart. Same filename/extension/size checks; works even
without an
installblock, since an upload carries no SSRF risk — handy for locally built mods (e.g..pakfiles on Palworld). The default per-file limit is 256 MiB; a module'smaxSizeMBoverrides it. The dashboard nginx, default ingress and API allow a complete multipart upload request of up to 512 MiB, including framing. A file configured at 512 MiB cannot use all of that limit in a local upload; larger module limits remain usable for URL installs. Custom ingress limits can further restrict uploads. - Extract mode (
extract: true) unpacks downloaded/uploaded.ziparchives into a per-mod folder (BepInEx-style layouts); listing and removal then operate on folders. Zip-slip and total-size are guarded. - Filenames are sanitized against path traversal; each
pathmust be relative with no...
Install manifest. Every mod volume carries a hidden
.gameplane-mods.json ledger the agent maintains: registry installs
record their provider/project/version, uploads record provider
upload, and files placed outside the panel show as unmanaged. This
powers the Mods tab's provenance badges, the batch update check
(GET /servers/{name}/mods/updates), and one-click in-place upgrades —
module authors get all of it for free; there is nothing to declare.
Declaring registry.providers turns the install page's browse mode on:
the dashboard searches the registry filtered to the active version's
loader + gameVersion and one-click installs the chosen file through
the same allowlisted download path (so keep the registry's CDN hosts in
allowedHosts).
The block below shows every supported provider's declaration shape for
reference — a single providers list may hold at most 8, so declare only
the ones your module actually needs.
registry:
providers:
- provider: modrinth # Minecraft mods/plugins (keyless)
modpacks: # optional Modpacks tab — see below
refEnv: MODRINTH_MODPACK
env: [{ name: TYPE, value: MODRINTH }]
- provider: curseforge # needs an admin API key + curseforgeGameID
curseforgeGameID: 432 # required for curseforge (Minecraft 432, ARK 83374)
- provider: hangar # PaperMC plugins (keyless)
- provider: thunderstore # BepInEx games (keyless)
community: valheim # required: the community slug
modpacks: {} # deps-mode packs (no refEnv)
- provider: factorio # official Factorio mod portal
- provider: steam # Steam Workshop; needs an admin API key
steamAppID: 4000 # required: the app to browse (4000 = Garry's Mod)
modpacks: # optional: browse Workshop Collections
refEnv: WORKSHOP_COLLECTION
- provider: nexus # Nexus Mods; needs an admin API key, browse-only
community: skyrimspecialedition # required: the Nexus game domain slug
- provider: spigot # SpigotMC plugins via the Spiget API (keyless)
- provider: github # GitHub Releases; browses ONE repo (keyless, rate-limited)
github:
owner: someorg # required: the repository owner
repo: somemod # required: the repository name
- provider: umod # uMod / Oxide plugins (keyless)- Up to 8 providers; the first is the dashboard's default and a switch
appears when there's more than one. A provider whose engine needs a
key (
curseforge,steam,nexus) is hidden until an admin sets one via the dashboard (PUT /admin/registries/{provider}/secret, backed by a labelled Secret) — CurseForge alone also accepts the legacy--curseforge-api-keystartup flag as a fallback when no admin key is set. thunderstorerequirescommunity;nexusreuses the same field as its game domain slug (e.g.stardewvalley,skyrimspecialedition— see Nexus's game list for the exact slug);steamrequiressteamAppIDinstead;githubrequires thegithubblock (owner/repo). The others ignore all of these.factoriois browse-only: the portal's downloads require the player's own factorio.com credentials, which Gameplane never stores — files are flaggedrequiresAuthand the dashboard hands the user to the from-URL form to append?username=…&token=…themselves. Keepmods.factorio.com/.factorio.cominallowedHosts.steamis browse-only, for a different reason: Steam Workshop content has no HTTP download URL at all — it's fetched bysteamcmdrunning inside the game container, using the app's own depot credentials. Browsing still works (titles, thumbnails, subscriber counts via the Steam Web API), butVersionsnever returns an installable file, so the Mods tab shows "No compatible files." for it. The one place this becomes genuinely one-click: declaremodpackswithrefEnvpointing at the game image's Workshop-collection-id variable (WORKSHOP_COLLECTIONon moststeamcmd-based images,HOST_WORKSHOP_COLLECTIONon some, e.g. joedwards32/cs2'sCS2_HOST_WORKSHOP_COLLECTION) — the Modpacks tab then browses Collections instead of individual items, and installing one just pins its id into that env var exactly like any other env-mode modpack.nexusis browse-only too, and deliberately stays that way: Nexus's real download links are minted per-caller (this API pod) and are premium-account-gated, but the agent pod in the game namespace is what actually performs the download — a different egress path a link minted for the API pod's IP is not reliably valid for. The agent's download path also doesn't sniff content-type, so a non-premium page URL would silently install as an HTML document.Versionstherefore never returns a file for Nexus, on purpose — there is norequiresAuth/from-URL fallback for it the way Factorio has, because Nexus has no self-service URL a user could complete themselves.spigotbrowses SpigotMC's plugin catalog through the third-party Spiget API (SpigotMC itself has no public REST API). Most resources install one-click, but some are flagged "external" (hosted on the author's own site, not on SpigotMC) or "premium" (a paid purchase Spiget has no way to authorize) — those report zero files for every version, the same "No compatible files." fallback as Steam/Nexus/Factorio-without- credentials, rather than a broken or misleading download link. Keepapi.spiget.organdcdn.spiget.orginallowedHosts— search/ metadata calls hit the former, and a hosted download 302s to the latter.githubbrowses exactly one repository's Releases, bound by thegithub.owner/github.repofields — GitHub has no cross-repo mod search, unlike every other provider here. Its "search" is a client-side filter over that one repo's release list, not a ranked search. Anonymous GitHub API calls are rate-limited to 60/hour per source IP, shared by every module using this provider from the same cluster egress address. Keep bothgithub.comand.githubusercontent.cominallowedHosts— a release asset'sbrowser_download_urlstarts ongithub.combut 302s to a*.githubusercontent.comhost, and the agent re-checks the allowlist on every redirect hop, so allowlisting only the first host makes the download fail partway through.umod(formerly Oxide) covers the Rust/Hurtworld/7 Days to Die plugin ecosystem. Downloaded files are raw.csC# source — Oxide/uMod compiles them inside the game process at runtime, so a.csfile is the normal, complete artifact, not a build step Gameplane skipped. Keepumod.orginallowedHosts; downloads are served directly from that host with no redirect.
Registry providers can declare a credentialsSecretRef to inject
authentication into downloads. Currently used by Factorio:
registry:
providers:
- provider: factorio
credentialsSecretRef: { name: factorio-creds }The Secret must live in the GameServer's namespace and contain username
and token keys:
apiVersion: v1
kind: Secret
metadata: { name: factorio-creds, namespace: gameplane-games }
type: Opaque
data:
username: <base64-encoded-username>
token: <base64-encoded-api-token>The agent mounts the Secret read-only at /etc/gameplane/mod-creds/factorio/
and transparently appends username and token query parameters to download
URLs during install. Missing or unreadable credential files are handled
gracefully (installs proceed without credentials). The credentials are never
logged or included in error messages.
Declaring modpacks on a provider adds a Modpacks tab. Two install
modes, chosen by refEnv:
- env-mode (
refEnvset): installing a pack pins its slug into the named env on the GameServer (plus any fixedenvlisted) and the server restarts — the game image installs the pack itself on boot (Modrinth packs on the itzg image). One pack active per server. - deps-mode (
modpacks: {}): the pack is resolved into its dependency mods, which install one-by-one through the normal install path (Thunderstore/BepInEx packs).
probes:
readiness:
httpGet: { path: /health, port: 8080 }
initialDelaySeconds: 30
failureThreshold: 10Many game images don't expose HTTP. The itzg/minecraft-server image
ships a mc-health exec probe; Source images usually expose nothing
useful, so readiness falls back to TCP on the game port.
spec.storage.mountPath is where the PVC is mounted inside the game
container. Pick the path the game writes its world/config files to —
backups snapshot this whole directory.
# 1. write modules/<name>/{template.yaml,module.yaml,README.md}
# 2. push to the local kind registry
modules/build.sh push --registry localhost:5001 --name <name>
# 3. wait for the default ModuleSource to pick it up (≤ refreshInterval)
kubectl get modulesource default -o jsonpath='{.status.modules[*].name}'
# 4. install via UI or CR
kubectl apply -f - <<EOF
apiVersion: gameplane.local/v1alpha1
kind: Module
metadata: { name: <name> }
spec:
source: { name: default }
name: <name>
EOF
# 5. verify the GameTemplate showed up
kubectl get gametemplate <name> -o yamlBump module.yaml#version, re-push under the new tag, and the catalog
reports an upgrade available within refreshInterval. Click Upgrade
in the UI (or kubectl patch module <name> --type merge -p '{"spec":{"version":"<v>"}}'). Keep template.yaml's spec.version in
lockstep with the bundle version — the official modules do, and drift
between the two confuses pinning.
Worth exercising manually for modules that declare spec.versions and
per-loader mods:
# switch the running server to another catalog entry (Settings → Version
# does the same) and watch the StatefulSet re-render
kubectl patch gameserver <gs> -n gameplane-games --type merge \
-p '{"spec":{"version":"<other-id>"}}'
# each (version+loader) combo gets its own PVC; the previous combo's
# volume must survive the switch
kubectl get pvc -n gameplane-games | grep <gs>-mods-From the dashboard, also try the Mods tab end to end: install one from
the registry (badge should show provider + version), Check updates,
upload a local file (badge upload), and remove it.