skip to content

Minecraft Server Setup for Development

How I personally setup development based minecraft servers for ease of use.

6 min read

The setup

Plugin work gets way faster once your local server is boring. Same folders, same steps, same loop every time. This is what I use for data-heavy plugin work, the stuff that’s mostly game state and tracking and persistence rather than flashy combat mechanics.

Steal the whole thing, or just the parts that fit what you’re building.

What I am optimizing for

A short feedback loop. That is the whole list. Write code, build the jar, get it into the server, reload or restart, test. That’s the cycle. Everything below exists to make it shorter or less annoying.

The kind of plugin this is for

My plugins are about state. They collect data, store it, and hand it to other parts of a game mode.

So stability comes first. Version choice, schema safety, and readable logs matter more here than particle effects.

IDE and project setup

IntelliJ IDEA, and I’m not really taking arguments on this one. Gradle support is solid, navigation is fast, and the built-in decompiler earns its keep when you need to read someone else’s plugin jar.

First thing that’ll annoy you is jar copying. A normal build drops the jar in build/libs, and your server wants it in server/plugins. So you can either copy it by hand after every build, or fix it once with a tiny Gradle task.

Gradle script that copies the jar for you

This is the minimal version. Swap in your own group, version, API version, and jar name.

plugins { java }
group = "me.yourname"
version = "1.0"
repositories {
mavenCentral()
maven("https://papermc.io/repo/repository/maven-public/")
}
dependencies {
compileOnly("io.papermc.paper:paper-api:1.20.4-R0.1-SNAPSHOT")
}
java {
toolchain.languageVersion.set(JavaLanguageVersion.of(17))
}
tasks.register<Copy>("copyToServer") {
val jarName = "MyPlugin-${project.version}.jar"
from(buildDir.resolve("libs/$jarName"))
into(layout.projectDirectory.dir("server/plugins"))
doLast { println("Copied $jarName to server/plugins") }
}
tasks.build { finalizedBy("copyToServer") }

You can get fancier later. Find the newest jar on its own, delete old snapshots, fail loud when the server folder is missing. This version covers the part that annoys you day to day.

Picking a version

Don’t chase a new Minecraft release on day one. I pick a Paper version that matches the production server, has the API hooks my plugin calls, and still gets community support.

If a client wants some weird version, ask why. Sticking to a supported baseline has saved me hours of rework more than once.

My checklist:

  • The target server runs that version today.
  • The plugin APIs I need exist and aren’t about to change.
  • Paper still publishes patches for that line.
  • Dependencies like LuckPerms and Spark publish builds for it.
  • The Java toolchain matches what the server wants, like Java 17 or newer.

How I lay out the local server

The dev server lives inside the project itself. Relative paths stay stable, and Gradle doesn’t have to guess where the server is.

MyPluginProject/
├── build.gradle.kts
├── settings.gradle.kts
├── src/
├── server/
│ ├── paper-1.20.4.jar
│ ├── eula.txt
│ ├── server.properties
│ └── plugins/

Only the basics live in server/: the Paper jar, eula.txt, server.properties, and plugins. If you need to test against multiple versions, make folders like server-1.20.4 and server-1.21 and point the copy task at the right one with a project property.

Config settings worth changing

Open server.properties and change the handful of things that matter for dev:

  • Set online-mode=false if you’re testing with a custom launcher. Turn it back on for production, obviously.
  • Leave enable-command-block=false unless a test needs command blocks.
  • Drop the view distance so your CPU stops suffering.
  • Allocate memory on purpose. 2G is plenty for a lean dev server.

Keep production tuning out of the dev config. If the file doesn’t fit on one screen something snuck in that shouldn’t be there.

Plugins that make debugging less painful

PlugManX

Loads, unloads, and reloads a plugin without a full server restart, which is what you want while iterating on commands and simple listeners.

That said, use full restarts for memory tests and anything with persistent caches. Class loaders love holding onto old singletons and static state after a reload, and then you’re debugging a ghost.

A throwaway test plugin

Write yourself a junk helper plugin. Dump events with it, trigger edge cases, seed test data.

It’s so much faster than stuffing temporary logic into the real plugin and hoping you remember to rip it out later. (You won’t.)

Spark

Profiles tick cost, thread usage, and performance hot spots. I run it after bigger refactors so regressions show up on my machine instead of someone’s live server.

LuckPerms

Add it early. Basic groups let you test real permission gates instead of running everything as op and finding the holes on someone else’s server.

Everything else can wait until a specific problem asks for it. Timing helpers, log tailing, a database viewer for embedded data, that kind of thing.

The loop itself

Same every time:

  • Make a code change.
  • Run build, which triggers the copy task.
  • Reload with PlugManX or restart the server.
  • Run the test command or scenario.
  • Watch the logs and the state.

If a reload leaves old listeners or stale state hanging around, restart instead. And keep an eye on how long the loop takes. Once it creeps past a minute something in there is worth automating.

Start scripts

One command to start the server. That’s the bar.

macOS and Linux

#!/usr/bin/env bash
cd "$(dirname "$0")"
java -Xms2G -Xmx2G -jar paper-1.20.4.jar nogui

Make it executable:

Terminal window
chmod +x start.sh

Windows

Terminal window
@echo off
cd /d %~dp0
java -Xms2G -Xmx2G -jar paper-1.20.4.jar nogui
pause

On JVM flags, skip the Aikar set until the server shows a real memory or GC problem. A dev server with two players on it will not notice them.

Stuff that has personally bitten me

  • Wrong Java version. The build passes and then the server rejects the plugin anyway.
  • Forgetting a version bump, so the old jar stays cached and your change looks like it did nothing.
  • Reloading for every test until stale static state starts lying to you.
  • The copy task running too early. finalizedBy keeps the order right.
  • Testing on a day-one Minecraft release, which loves breaking API assumptions.

Things I might do later

None of these have earned a spot yet, but they’re on the list:

  • Docker, once more than one person needs this setup.
  • Building jars in CI and attaching them to releases.
  • Integration tests that spin up a headless server.
  • Incremental hot swap with a debug agent.
  • A multi-version test matrix for plugins that support several branches.

For now the setup is small enough that I’d rather leave it alone than automate something I run twice a month.