The refresh script kept its own copy of the pack directory and the three URLs, so every deployment had to hand-edit it after running the installer — the last manual step and an easy one to forget or get wrong. It now sources deploy.conf when one sits next to it and derives PACK_DIR, BASE_URL, CLIENT_URL and PACK_URL from PACK_NAME, SHARE and MIRROR_URL. The CONFIG block remains as the fallback when there is no config file, so the script still works standalone. CLIENT_URL was also referenced by the sync but never defined in the CONFIG block, which would have been an unbound variable under set -u on the first run with client mods. Verified both the with-config and without-config paths. Also gitignore deploy.conf and DEPLOY-NOTES.md as machine-local files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|---|---|---|
| __pycache__ | ||
| .gitignore | ||
| README.md | ||
| build.sh | ||
| deploy.conf.example | ||
| deploy.sh | ||
| install.sh | ||
| mc-refresh-restart.sh | ||
| mc-service-setup.sh | ||
| packwiz-http.py | ||
| packwiz-setup.sh | ||
README.md
mc-packwiz-deploy
Self-hosted NeoForge Minecraft server with a packwiz modpack that clients pull automatically on every launch. Mods are served from an HTTP mirror of the server's own mods directory — no Modrinth or CurseForge dependency.
Everything lives on a ZFS dataset mounted at /minecraft.
Quick start
./deploy.sh -n "Opengames" -a themiro \
-u https://games.abdelbaki.eu/mods \
--accept-eula
Add -p 25599 to run on a non-default port. It's written into
server.properties, opened in firewalld, and shown in the player guides — which
print a bare hostname on 25565 and host:port on anything else, since Minecraft
assumes the default and a needless :25565 confuses people.
The NeoForge version is resolved automatically — the newest stable build in the
series matching your Minecraft version, from the NeoForged maven. Pin one with
-N 21.1.248 if you'd rather not track latest.
Then:
sudo systemctl start minecraft
journalctl -u minecraft -f
Send the players https://<your-host>/setup-windows-packwiz.html or
setup-linux-packwiz.html. Those are generated with your real pack URL and
server address already filled in.
Deploying from an ISO or USB stick
./build.sh --iso # -> dist/mc-packwiz-deploy-<version>.iso
Burn or dd it, mount it on the target, then:
mkdir -p ~/mc-packwiz-deploy
cp -r /run/media/*/mc-packwiz-deploy/. ~/mc-packwiz-deploy/
cd ~/mc-packwiz-deploy && chmod +x *.sh *.py
$EDITOR deploy.conf # set MIRROR_URL and ACCEPT_EULA at minimum
./install.sh --check # verify the machine is ready
./install.sh
install.sh reads everything from deploy.conf, so there are no flags to type
at a console. It installs missing prerequisites with dnf, optionally caps the
ZFS ARC, runs the deploy, and can start the server. --check validates
prerequisites and the mount without changing anything.
The one thing it will not do is guess about the EULA: ACCEPT_EULA must be set
to yes in the config, or it stops and points at the licence.
It also refuses to run when the share is not a mountpoint, since deploying onto an unmounted path would fill the root disk instead of the pool.
Re-running
deploy.sh is safe to run repeatedly, and re-running is the intended way to
apply changed settings — a port change, a different heap size, or a run that
failed partway and left things half-applied.
Each run:
- Snapshots the dataset (
<pool>@predeploy-<timestamp>) - Stops the server if it was running
- Rewrites
server.properties,user_jvm_args.txtand both unit files - Moves any existing pack to
<pack>.bak.<timestamp>and rebuilds it - Regenerates the player guides
- Starts the server again if it had been running
The world and /minecraft/mods are never touched. The pack is moved aside
rather than deleted, so hand-edited .pw.toml files and any git history in the
pack directory survive.
--keep-pack skips the pack rebuild; --no-snapshot skips step 1.
What's in here
| Script | Runs as | Does |
|---|---|---|
deploy.sh |
you | Runs the two setup scripts in order |
mc-service-setup.sh |
root | minecraft user, NeoForge server, systemd units |
packwiz-setup.sh |
you | packwiz install, pack init, mod index, player guides |
mc-refresh-restart.sh |
you | Re-sync the pack after changing mods, then restart |
build.sh |
you | Builds the release zip |
Pack HTTP server
packwiz-http.service serves the pack, the mod jars and the setup guides on
port 18080 (-H to change, -B to change the bind address, --no-http to skip
it). Put a reverse proxy in front of it.
It serves an allowlist, not the whole share: mods/, packs/, and
setup-*.html. Everything else 404s. That is deliberate — the same directory
holds server.properties (which can carry an rcon password), ops.json,
whitelist.json, usercache.json, logs and the world.
pack.toml and index.toml are sent with Cache-Control: no-cache, so the
proxy can't hand clients a stale pack. Jars are pinned by hash in the index and
cache freely.
If the mirror is only reachable over plain HTTP from the machine building the
pack, but clients need the public HTTPS URL, use -S https — it rewrites the
scheme on every generated URL.
Memory
The heap defaults to 10 GB (-X/-x to change it). -XX:+AlwaysPreTouch means
the JVM commits the whole heap at startup rather than growing into it, so the
number you set is the number actually taken.
On a ZFS host, cap the ARC to go with it — ZFS defaults to using up to half of RAM for cache, and will otherwise contend with the JVM for the same pages:
echo "options zfs zfs_arc_max=2147483648" | sudo tee /etc/modprobe.d/zfs.conf
sudo dracut --force && sudo reboot
That pins the ARC to 2 GB. On a 16 GB box that leaves roughly 4 GB for the OS and anything else the host runs, which is comfortable.
Requirements
- Fedora (or any systemd distro;
dnfcalls need swapping otherwise) - A ZFS dataset mounted at
/minecraft - Java 21
- A web server already publishing
/minecraft—/modsand/packsmust be reachable over HTTP
Every script refuses to run if /minecraft isn't a mountpoint. An unmounted
dataset leaves it as an empty directory on the root filesystem, and writing a
world or a mod mirror there fills the root disk instead of the pool.
Client-only mods
Rendering and UI mods — Sodium, Iris, Embeddium, Oculus, shader mods — link
against LWJGL, which does not exist on a dedicated server. Put one in mods/
and the server dies at boot with NoClassDefFoundError: org/lwjgl/Version.
Two directories, both mirrored over HTTP:
| Directory | Loaded by the server | In the pack as |
|---|---|---|
/minecraft/mods |
yes | side = "both" |
/minecraft/client-mods |
no | side = "client" |
Drop client-only jars in client-mods/ and they reach players normally while
the server never sees them — packwiz-installer -s client fetches both sides,
and nothing server-side reads the second directory.
The side field is set explicitly after each packwiz url add, since packwiz
writes both by default.
Day-to-day
Add or remove a jar in /minecraft/mods, then:
./mc-refresh-restart.sh
That registers new mods, re-pins any whose file changed, drops metadata for deleted ones, takes a ZFS snapshot, restarts the server, and prunes to the 8 most recent snapshots. Players get the change on their next launch.
It refuses to restart if any mod failed to register, so the server never comes
up on a half-synced pack. Edit the config block at the top first — PACK_DIR,
BASE_URL, and PACK_URL need to match your deployment.
The server unit
minecraft.service holds a console FIFO at /run/minecraft/console. That's
what makes a clean shutdown possible:
sudo systemctl stop minecraft # sends "stop", world saves properly
echo "say hello" | sudo tee /run/minecraft/console
journalctl -u minecraft -f # console output
Without the FIFO, systemd would SIGTERM the JVM and risk cutting a world save in half.
The service creates and opens the FIFO itself rather than delegating to a
socket unit. SELinux denies init_t read/write on a fifo_file in /run, so
anything routed through PID 1 fails with EACCES on a stock Fedora policy.
Web server
/minecraft needs to be served over HTTP. Serve pack.toml and index.toml
with Cache-Control: no-cache — a caching proxy will otherwise hand clients a
stale pack while the server runs the new one. mc-refresh-restart.sh checks for
exactly that and stops before restarting if it finds it.
Caveats
packwiz-setup.shprobespackwiz --helpat runtime rather than assuming flag names, since the NeoForge options have moved between builds. If it bails with an interface complaint, the installed packwiz differs from what's expected.- Mod names come from filenames with the trailing version stripped
(
create-1.21.1-6.0.4.jar→create). It's a heuristic; rename the generated.pw.tomlfiles if you don't like the result. - Hashes are pinned. Replacing a jar in place under the same filename requires a re-sync, or clients fail the hash check.