Keep plugin jars and configs identical across Minecraft servers, using a Git repository as the source of truth.
One repository holds the jars and config files you care about. Every server runs GitSync, renders that repository into its own plugins/ directory, and runs the right reload command for whatever actually changed. Edit a config once, commit, and every server picks it up.
Servers are not all the same, so the pack is layered. base/ is what every server gets, role/<role>/ is what every server of one kind gets, and instance/<instance>/ is one server alone. A higher layer replaces the whole file.
You decide what is in the pack. A plugin is synced only once you add it to pack.json - everything else in plugins/ is left completely alone, keeping whatever jar and config that particular server has.
The repository is cloned into plugins/GitSync/pack/, and plugins/ is rendered from it. Rendering is a plain copy: for every path pack.json declares, the highest layer that holds it wins, and the file is written into plugins/.
plugins/
├── GitSync/
│ ├── config.yml <- remote, branch, credentials
│ ├── server.yml <- role, instance and variables of this server
│ ├── render-state.json <- what the last render wrote, do not edit
│ └── pack/ <- the repository
│ ├── pack.json <- declares what is synced
│ ├── base/ <- every server
│ ├── role/city/ <- every city server
│ └── instance/city-1/ <- this one server
├── ItemsAdder_4.0.17.jar <- rendered from the pack
├── ItemsAdder/ <- only the declared paths inside are rendered
└── SomeOtherPlugin/ <- never touched, not in pack.json
GitSync remembers what it wrote in render-state.json. A file whose content no longer matches that was edited on this server, so a sync that wants to change it stops instead, names the files, and waits - either publish those edits with /gitsync pushupdate, or throw them away with /gitsync sync --force. Nothing outside pack.json is ever written or deleted.
Lives in the root of the remote repository and declares what belongs to the pack:
{
"plugins": {
"ItemsAdder": {
"pluginJarWildcard": "ItemsAdder_*.jar",
"configPaths": [
"ItemsAdder/contents",
"ItemsAdder/storage/custom_fires_ids_cache.yml",
"ItemsAdder/storage/font_images_unicode_cache.yml",
"ItemsAdder/storage/items_ids_cache.yml",
"ItemsAdder/storage/real_blocks_ids_cache.yml",
"ItemsAdder/storage/real_blocks_note_ids_cache.yml",
"ItemsAdder/storage/real_transparent_blocks_ids_cache.yml",
"ItemsAdder/storage/real_wire_ids_cache.yml"
],
"reloadCommands": [
"iareload"
]
},
"EssentialsX": {
"pluginJarWildcard": "EssentialsX-*.jar",
"configPaths": [
"Essentials/config.yml"
],
"reloadCommands": [
"ess reload"
]
}
}
}| Field | Meaning |
|---|---|
pluginJarWildcard |
Glob for the plugin jar in the root of plugins/. The wildcard means you do not have to touch pack.json on every version bump. A jar in a higher layer hides the one below it, even under a different file name. |
configPaths |
Files or directories to sync, relative to plugins/. A directory syncs everything under it. |
reloadCommands |
Run from the console when any of the paths above change. |
All three are optional. Drop reloadCommands for a plugin that has to be restarted anyway, or pluginJarWildcard for a config-only entry.
Paths are the same in every layer: base/Essentials/config.yml and role/city/Essentials/config.yml both mean plugins/Essentials/config.yml.
plugins/GitSync/config.yml - how to reach the repository:
# Remote repository holding pack.json, plugin jars and configs. Leave empty to disable syncing.
remote: "https://github.com/you/your-pack.git"
# Branch to track
branch: "main"
# Credentials for private repositories. On GitHub use a personal access token as the password.
username: ""
password: ""
# How often to check the remote for new commits (seconds)
checkIntervalSeconds: 300
# Sync during the bootstrap phase, before any plugin loads, so synced configs are read by their owners.
# New plugin jars cannot be loaded during a boot that already started, so the server stops to be restarted with them.
syncOnStartup: trueplugins/GitSync/server.yml - which layers this server renders:
# Server type, rendered from role/<role>/ over base/. Shared by every server of this kind.
role: "city"
# This one server, rendered from instance/<instance>/ over the role. Empty for none.
instance: "city-1"
# What this server puts into the synced files.
variables:
SERVER_NAME: "city-1"
DB_PASSWORD: "${DB_PASSWORD}"role and instance resolve ${VAR} from the environment, so role: "${GITSYNC_ROLE}" picks up an egg variable on Pterodactyl instead of being typed into every server. Variable values do the same.
A field that differs per server does not need a whole file in role/ or instance/ to override it. Write the name in the packed file with the GITSYNC_ prefix - server.yml declares it without:
serverName: ${GITSYNC_SERVER_NAME}
password: ${GITSYNC_DB_PASSWORD}Rendering puts this server's values in, and /gitsync pushupdate takes them back out, so the pack keeps the ${GITSYNC_NAME} and the other servers keep theirs. Lines are matched by content, so editing the file around a variable - or adding and removing lines - does not disturb it.
A default can ride in the placeholder itself, ${GITSYNC_PORT:25565} - servers that declare the name use their value, the rest use the default.
Only names this server declares are touched. A ${GITSYNC_...} the pack uses but server.yml does not set, and that carries no default, is left in the file as it stands: the startup sync stops the server and names the missing variables, a sync at runtime is abandoned before anything is written. Anything a server does not declare at all is left alone entirely, so the ${placeholder} syntax other plugins use is safe.
Editing the variable's own line by hand is the one thing that cannot be undone - the value and the edit are indistinguishable. That stops the commit, names the lines, and offers a [publish anyway] button (--confirm at the end of the message from the console), because publishing sends this server's value to everyone.
All of them need the gitsync.admin permission.
| Command | What it does |
|---|---|
/gitsync sync [--force] |
Pull and render now. --force takes the pack as-is: edits made on this server are overwritten instead of stopping the sync. |
/gitsync status |
Remote, branch, commit, layers, what the pack contains - and whether the server needs a restart. |
/gitsync reload |
Reload config.yml and server.yml, and restart the periodic check. |
Configs are meant to be tweaked in place. Do it, then publish:
| Command | What it does |
|---|---|
/gitsync git status |
Which synced files were edited here, and which layer each one would go back to. |
/gitsync git diff [path] |
What differs between the pack and the files on this server. |
/gitsync pushupdate <message> [--confirm] |
Copy those edits back into the layer each file came from, variables restored, then commit and push. A file created here goes to the role layer; a file deleted here is dropped from its layer, which re-exposes the copy below it. --confirm publishes variable lines that were edited by hand. |
/gitsync git showahead |
Commits that exist locally but not on the remote. |
/gitsync git resethead |
Throw away the local edits and render the pack over them again. |
A commit also looks for jars in plugins/ that no pack.json entry claims. For each one it asks in chat where the plugin belongs - [base], [role/<role>], [instance/<instance>] or [ignore] - and the click does the rest. Only the jar joins the pack; add its configPaths by hand, because which of its files are config and which are player data is not something to guess at. [ignore] remembers the plugin in data.yml so the same jar is never asked about twice.
Not every change can be applied by a reload command, so /gitsync status tracks the ones that cannot and names them:
--- GitSync ---
Remote: https://github.com/you/your-pack.git
Branch: main
Commit: a1b2c3d Update ItemsAdder to 4.0.17
Layers: base -> role/city -> instance/city-1
Pack: 2 plugin(s): ItemsAdder, EssentialsX
Auto sync: every 300s
Restart required: YES
ItemsAdder_4.0.17.jar (plugin jar changed)
SomePlugin/config.yml (no reload commands declared)
A restart is flagged when a sync adds, removes or updates a plugin jar, or when it changes a config of a plugin whose entry declares no reloadCommands. A plugin whose jar changed is also not reloaded again until that restart; every other plugin keeps reloading normally. The flag lives in memory only - the restart it asks for is what clears it.
A config is useless if its owner already read the old one, so GitSync syncs from a Paper plugin bootstrapper - the phase that runs before any plugin is loaded. By the time ItemsAdder or EssentialsX starts, their configs are already the ones from the repository.
Jars are the exception, and no plugin can work around it: the server scans plugins/ and builds its plugin list before the bootstrap phase runs. A jar rendered during that sync cannot be loaded into a boot that already began. So when a sync replaces a jar during startup, GitSync logs it and stops the server, expecting the wrapper (systemd, Pterodactyl, Docker, a start script loop) to bring it back up with the new jars in place. Without an auto-restarting wrapper the server will simply stay down. Set syncOnStartup: false if you would rather sync only on the timer and handle jars yourself.
Earlier versions made plugins/ itself the repository. To move over:
- In the remote repository, move everything except
pack.jsonunderbase/(git mv), and split offrole/<role>/where servers differ. - Set
roleinserver.ymlon each server. - Delete
plugins/.gitandplugins/.gitignoreby hand. GitSync warns in the console until you do.
Files already on disk that match the pack are adopted silently, so the first sync after the move only reports what genuinely differs.
./gradlew build
The shaded jar lands in build/libs/. Requires JDK 21.
Requires Paper 1.21+ - the plugin uses paper-plugin.yml and a bootstrapper, so it does not run on Spigot.
When GitSync creates the repository it sets a few things once, so commits from a server are recognisable and Windows/Linux servers can share one repo:
user.name = MC Server
user.email = minecraft@server.null
core.fileMode = false
credential.helper = store