I wanted to run a Minecraft server before I graduated high school. Not just host one. I wanted to design and engineer the whole thing: game logic, infrastructure, CI/CD pipeline, database layer. What I ended up with was a browser-playable economy server on a two-node Raspberry Pi cluster, running a PaperMC plugin I wrote from scratch, a PostgreSQL database, three Docker services orchestrated by Swarm, and a GitHub Actions pipeline that ships new images on every push to main.

Across two weeks in late January and February 2026, I took a bare Raspberry Pi to 100 players, working shops, and competitive land ownership. Here’s how I built it, broke it, and fixed it.

The idea

Players join, earn currency by playing, buy and claim land, and open shops to sell items to each other. Whoever has the most money tops the /baltop leaderboard. Simple premise. The work was in building infrastructure to support it properly: land ownership backed by a real database, an economy with genuine incentives, shops with inventory management.

The second goal was access. I wanted friends to join from a school Chromebook with no Minecraft account and no launcher. So I built around EaglerCraft, a WebAssembly port of Minecraft 1.8 that runs in the browser. The server had to speak both the Java Edition protocol and EaglerCraft’s WebSocket protocol at once.

The network architecture

Three services run together.

Velocity is the proxy. Every connection arrives here first, whether from a Java client or a browser running EaglerCraft over WebSocket. Velocity authenticates the player and routes them to a backend.

PaperMC 1.8.8 runs the survival world and all the custom plugin logic.

NanoLimbo is a near-zero-resource holding server. New players land here before they authenticate. Once they prove who they are, Velocity moves them to survival.

EaglerCraft support comes from the EaglerXServer plugin running inside Velocity, which translates the WebSocket handshake. PaperMC sees every player as an ordinary Java connection and knows nothing about the browser clients. That split is what let me support both client types without touching the game server.

All three services sit on a Docker overlay network (10.99.0.0/24) and reach each other by service name. The backend ports stay private (survival on 30002, limbo on 30001). Only Velocity’s 25565 is published.

Docker: images that update themselves

My most important infrastructure decision was to treat configuration as part of the image, not the volume.

Each process has its own Dockerfile. The survival image copies the PaperMC jar, every config file (server.properties, bukkit.yml, spigot.yml, paper.yml), and the compiled plugin JAR into an /app-image-data staging directory baked into the image. The working directory, /app, is a Docker volume, so world data and plugin state survive restarts.

The entrypoint ties it together. On every container start, it syncs config from the image into the volume before launching the JVM:

cp "${IMAGE_DATA_DIR}/paper-server.jar" "$VOLUME_DIR/"
cp "${IMAGE_DATA_DIR}/server.properties" "$VOLUME_DIR/"
cp "${IMAGE_DATA_DIR}/plugins"/*.jar "$VOLUME_DIR/plugins/"
# ... all other config files

Updating the server means updating the image. Pull, restart the stack, and the entrypoint overwrites the JARs and config while leaving world data alone. No SSH-ing in to drop a plugin JAR by hand. The volume holds state; the image holds code and config.

The JVM flags live in the entrypoint too. Survival starts with a full Aikar G1GC configuration tuned for Minecraft’s allocation patterns:

exec screen -DmS minecraft java \
    -Xmx4G -Xms128M \
    -XX:+UseG1GC \
    -XX:G1HeapRegionSize=8M \
    -XX:MaxGCPauseMillis=200 \
    -XX:G1NewSizePercent=30 \
    -XX:InitiatingHeapOccupancyPercent=15 \
    -jar paper-server.jar nogui

I run the server inside screen so I can attach for live console access without stopping the container.

GitHub Actions: every push ships

Three workflows, one per service. Each watches its own paths. Change survival-manager/ or network/survival/ and only the survival image rebuilds. Touch network/velocity/ and only Velocity rebuilds. Limbo has no compile step, so its workflow just repackages config.

The survival and velocity workflows compile first. Survival runs through five steps:

  1. Set up JDK 21
  2. Run ./gradlew build --no-daemon in survival-manager/
  3. Copy the built JAR into network/survival/plugins/
  4. Build the Docker image, now with the fresh JAR in context
  5. Push to GitHub Container Registry (ghcr.io/youngermax/eaglercraft-v2/survival:latest)

Every workflow builds for both linux/amd64 and linux/arm64 using QEMU and Buildx. That multi-arch build is what makes Raspberry Pi deployment painless. GitHub’s runners are AMD64, the ARM64 layer gets emulated at build time, and the Pi pulls a native ARM image.

Layer caching runs through GitHub Actions cache (cache-from: type=gha, cache-to: type=gha,mode=max), so builds that don’t touch dependencies finish fast. Each push tags :latest plus a SHA-prefixed tag (main-<sha>) for traceability.

Docker Swarm: two Pis, one logical host

The cluster is two Raspberry Pis. The docker-compose.yml is a Swarm stack file, and deploying is one command:

docker stack deploy eaglercraft-server-network \
    --with-registry-auth \
    --compose-file=docker-compose.yaml

Placement constraints pin services to nodes. postgres and survival run on cluster-2. velocity runs on cluster-1. limbo has no constraint, so Swarm places it wherever there’s room.

The overlay network lets containers on different physical machines talk as though they share a LAN. Postgres never gets exposed; survival connects to it over the overlay by service name. Named volumes (velocity-data, survival-data, postgres-data) hold persistent state. The TLS certificates for EaglerCraft’s WSS connection ride in as Swarm configs, mounted into Velocity with restricted permissions (mode: 0600 on the private key).

This replaced an earlier Coolify deployment. Coolify gave me a UI but hid what was actually running and added a management layer I didn’t fully control. Swarm is explicit. The stack file is the source of truth, it lives in the repo, and docker stack deploy is idempotent.

The day-to-day cycle

Code change to running server goes like this:

  1. Push to main
  2. GitHub Actions compiles the plugin, builds the image, pushes to GHCR
  3. On the cluster, docker stack deploy pulls the new :latest images and does a rolling restart
  4. Each entrypoint syncs the new JARs from image into volume
  5. The server comes back up on the new code

The Pis carry no build tooling. They only run things. Gradle compilation, multi-arch Docker builds, and image pushes all happen on GitHub’s infrastructure.

How the infrastructure got here

The deployment didn’t start this clean. The git history shows the climb.

January 26Eagler Server v2.0.0. The server runs but has no deployment automation. An update.sh script has to be run by hand on the machine.

January 31dockerize + gh actions. Three Dockerfiles and three workflows land in one commit. This is where the server gets a real pipeline. The same day brought a Gradle 9.0 upgrade, the big plugin refactor (2,822 lines added, 2,291 removed), and scrapping the daily reward for the per-hour variable one.

February 1use docker compose. Containers move from manual docker run to a compose file. Both Dockerfiles gain screen so I can attach without stopping them.

February 8 — Six commits in one day. I removed dead OpenRC service files left over from the Alpine era, fixed land permission toggles that weren’t persisting, fixed explosion damage on protected land, and added the ELITE rank. The february 8 update commit alone touched 132 files and added 7,478 lines, rewriting the plugin’s internals from an unstructured monolith into a clean API/impl split.

February 13update docker to use swarm, migrate away from coolify. Added the .env template, the overlay network, node placement constraints, and Swarm configs for the TLS certificates. The flat compose file became a stack file.

The plugin architecture

survival-manager is a PaperMC plugin in Java 21. After the January 31 refactor, it separates API from implementation. The API lives in server/api/ as interfaces: SLand, SPlayer, SEconomy, SInviteList. The implementations live in server/impl/. Commands and listeners program against the interfaces, so I could swap the flat-file economy for a Postgres-backed one without touching the command layer.

There’s a small GUI framework too. Inventory GUIs — the shop editor, the land purchase screen, the permissions manager — build on a Gui base class and a GuiComponent abstraction, with Button and ToggleButton components and a GlobalGuiPubSub system that routes click events. About 200 lines of framework made every GUI consistent and far less tedious to write.

Commands use a small argument-parsing library in lib/command/. A command declares its arguments up front:

public InviteCommand() {
    addStringArg("username").description("The username of the person who will be joining.");
}

The framework parses, coerces types, and generates error messages. A new validated command takes about 20 lines.

FlexORM: my own database layer

I skipped Hibernate and jOOQ, both awkward inside a Bukkit plugin’s classloader, and wrote a lightweight query builder called FlexORM. It generates a typed interface from the schema:

orm.players
    .select()
    .where(new FlexORM.Equal<>(orm.players.uuid, player))
    .limit(1)
    .execute()
    .getFirst();

No query-time reflection, no annotation scanning, no session management. Type-safe SQL over JDBC. It has no lazy loading, no joins, and no relationship mapping, but it’s correct, it’s fast, and it matches the plugin’s access patterns. The generated FlexORM.java carries a WARNING: AUTO-GENERATED FILE header and gets regenerated when the schema changes.

The economy: variable-ratio rewards

Players earn money by playing, but not on a linear schedule. Every block broken or placed carries a 1% chance of paying $500, capped at one payout per hour. Enchanting pays at 50%, much higher, because enchanting is already a deliberate, costly action.

@EventHandler
public void onMining(BlockBreakEvent event) {
    randomReward(event.getPlayer(), 0.01, "Woah! The block you just mined had $% in it!");
}

@EventHandler
public void onEnchant(EnchantItemEvent event) {
    randomReward(event.getEnchanter(), 0.5, "Woah! The enchantment gods blessed you with $%!");
}

The hourly cap sits in a ConcurrentHashMap<UUID, RewardTracker>, a sliding window that resets after 60 minutes. No database hit per block break. The whole thing resolves in memory.

The server first had a daily login reward. I pulled it the day I tested it. Predictable rewards don’t build the same engagement loop. With the variable schedule, players mine about 20 blocks on average before their first payout — early enough to set the pattern before they quit, late enough that it still feels like a surprise. Removing the daily reward and adding the probabilistic one happened in a single commit: Remove daily reward, uncap invites, make money bonus capped per hour.

Land pricing: reverse-engineering the world generator

When a player runs /land buy, the chunk has a price. The price is deterministic, so a chunk always costs the same, but it varies across the map and players can’t easily guess it.

Two components set it.

Biome value scales with rarity. Mushroom islands are $500 base, oceans and deserts $50, plains and forests $100, jungles and the Nether $200, mesa and mega taiga $300. Each base value gets multiplied by a factor between 0.8× and 1.1×, seeded from the world seed and chunk coordinates:

Random random = new Random(
    chunk.getWorld().getSeed() + chunk.getX() * 5432L + chunk.getZ() * 1512L
);
long biomeValue = (long) (baseValue * random.nextDouble(0.8D, 1.1D));

Structure proximity adds value for chunks near generated structures. I reverse-engineered Minecraft 1.8.8’s procedural generation to locate villages, strongholds, ocean monuments, and nether fortresses without exploring the world. The bonus falls off as a cosine curve from each structure’s center:

double locationValue = Math.cos((distance / distanceFloor) * (Math.PI / 2d)) * priceFactor;

A chunk next to a stronghold gains up to $1,000. The locator, WorldGenFeatureComputer, reimplements Minecraft’s seeded region-based placement, down to the magic seed constants from the original source (341873128712L, 132897987541L). It’s about 200 lines of reconstructed world generation.

The land system in memory

PostgreSQL stores land ownership, but at runtime the whole map lives in a three-dimensional array: SLandChunk[worldIndex][mapX][mapZ]. The world is bounded to a 200-chunk radius around spawn. On startup I load all land and all permissions in exactly two SQL queries — one for chunks, one for permissions — index the permissions by chunk key for O(1) lookup, and fill the array.

Every block interaction (breaking, placing, opening chests, detonating TNT) calls LandImpl.hasPermission(), a direct array index. No database hit, no cache check.

A chunk is in one of three states: OwnedLandChunkImpl (claimed, permissions enforced), AnarchyLandChunkImpl (unclaimed, open to everyone, and home to getLandValue()), or OutOfBoundsLandChunkImpl (past the buyable radius, nothing permitted). Permission resolution is a short chain: ops bypass everything, owners always pass, then global permissions, then per-player overrides.

An early bug hit the OwnedLandChunkImpl toggle logic — toggles didn’t survive a restart. The fix (fix permissions not toggling) touched six files and rewrote how the ORM’s update path handled the permissions table. It shipped alongside three other fixes the same morning on February 8.

Results

The server ran most of February 2026 on hardware that cost nothing past electricity. With the pipeline in place, fixing a bug and getting it live took about as long as a GitHub Actions run, usually a few minutes. Swarm let me treat the two Pis as one logical host and place services by constraint instead of by hand.

If I did it again, I’d start on PostgreSQL. The SQLite-to-Postgres migration needed a 230-line Python script to run, and it was annoying enough that I deleted the script in the same commit I finished the migration. Starting on Postgres from day one would have cost me nothing.