Skip to content

Repository files navigation

Minecraft Server (Paper) on Docker Compose

Deployment Verification OpenSSF Best Practices License: MIT

Contents

This repository deploys a Paper Minecraft server with automatic plugin installation (Modrinth + direct URLs, Geyser/Floodgate for Bedrock crossplay out of the box) and a scheduled world backup container. One docker compose up away from a survival server your friends can join.

📙 Full narrative installation guide on the blog: heyvaldemar.com/install-minecraft-server-using-docker-compose/.

Why this stack?

Need This stack Manual install Other compose examples
Ready to deploy in <10 min ✅ ❌ java, jars, systemd Often
Plugins auto-installed on start ✅ Modrinth + URL lists Manual downloads Rare
Bedrock crossplay (Geyser/Floodgate) preconfigured ✅ Manual setup Rare
Scheduled world backups + pruning ✅ RCON-coordinated mc-backup Manual cron + save-off dance Rare
Upstream images pinned by sha256 digest ✅ N/A Almost never (usually latest)
Weekly pin-freshness check in CI ✅ N/A Rare
CI-verified deployment on every push ✅ boots a real server N/A Rare
Every setting tunable via env ✅ 40+ knobs with sane defaults server.properties by hand Varies

Two moving parts (server + backups sidecar). The heavy lifting comes from the excellent itzg/minecraft-server and itzg/mc-backup images, pinned and wired together.

Prerequisites

  • A server (Linux recommended) with Docker Engine 24+ and Docker Compose 2.20+.
  • ~3 GB free RAM (2 GB heap default + overhead) and a CPU core or two; more for many players or heavy plugins.
  • Port 25565 open (TCP) on the firewall for Java Edition; Geyser's Bedrock port needs extra config if you use it beyond LAN.
  • Disk for the world and backups: worlds grow; the default retention keeps 7 days of archives.

Getting started

# 1. Clone
git clone https://github.com/heyvaldemar/minecraft-server-docker-compose
cd minecraft-server-docker-compose

# 2. Create the Docker network the stack expects
docker network create minecraft-server-network

# 3. Copy the environment template and set the RCON password
cp .env.example .env
$EDITOR .env
# ^ Required: MINECRAFT_SERVER_RCON_PASSWORD. Everything else has defaults.
#   Keeping MINECRAFT_SERVER_EULA=true means you accept the Minecraft EULA.

# 4. Deploy
docker compose -f minecraft-server-docker-compose.yml -p minecraft up -d

First boot downloads the Paper jar and the configured plugins, then generates the world. Give it a few minutes. Players connect to your-server-ip:25565.

What success looks like

# The server container turns healthy once it answers a Server List Ping:
docker compose -f minecraft-server-docker-compose.yml -p minecraft ps

# RCON works (lists online players):
docker compose -p minecraft exec minecraft-server rcon-cli list

# Watch the boot log:
docker compose -p minecraft logs -f minecraft-server

# Backups land in ./minecraft-server-data-backups on the schedule:
ls minecraft-server-data-backups/

Common first-deploy issues

  • Container exits immediately with an EULA message. MINECRAFT_SERVER_EULA must be true (you are accepting the Minecraft EULA).
  • docker compose up fails with set in .env. MINECRAFT_SERVER_RCON_PASSWORD is empty: generate one per .env.example.
  • network minecraft-server-network not found. Step 2 was skipped.
  • Slow first start. Paper jar + plugins download once; later starts are much faster.

Apply .env or compose-file changes

docker compose -f minecraft-server-docker-compose.yml -p minecraft up -d --force-recreate

Features

  • Paper server (TYPE=PAPER, VERSION=LATEST by default: pin a game version via MINECRAFT_SERVER_VERSION for stability).
  • Automatic plugin install from Modrinth project slugs and direct download URLs, with dependency resolution.
  • Bedrock crossplay ready: Floodgate ships in the default plugin list; pair with Geyser to let Bedrock players join.
  • A health check that asks the server. mc-monitor status, run inside the container, sends a Server List Ping, the query the multiplayer screen sends, and is healthy only on the server's status reply. It is written out in the compose file rather than left to the image's mc-health, which reads /data/.mc-health.env from the world directory (a file there can switch it off) and calls a stopped JVM healthy whenever ENABLE_AUTOPAUSE is on. Each check ends within 5 seconds, inside Docker's 10. CI freezes the process that owns the port with SIGSTOP and requires red while the JVM is still in the process table, then green after SIGCONT. It asks from inside the container, so it does not prove the port is published on the host or open in a firewall.
  • RCON enabled for admin commands (rcon-cli inside the container) and coordinated backups.
  • RCON-coordinated backups: mc-backup runs save-off/save-all around each archive so world saves are consistent, then prunes archives older than the retention window.
  • 40+ game settings (mode, difficulty, view distance, whitelist, ops, world type…) exposed as env vars with compose-level defaults.
  • Local bind mounts: world data in ./minecraft-server-data, archives in ./minecraft-server-data-backups, custom plugin jars in ./plugins.

Plugins

Two mechanisms, combinable:

  • MINECRAFT_SERVER_MODRINTH_PROJECTS: comma-separated Modrinth slugs (default: viaversion,viabackwards,skinsrestorer), with MODRINTH_DOWNLOAD_DEPENDENCIES=required.
  • MINECRAFT_SERVER_PLUGINS: newline/comma-separated direct jar URLs (default: latest Floodgate build).
  • Drop .jar files into ./plugins/ for anything not available by URL.

Plugins are re-resolved on every container start, so version bumps arrive with a --force-recreate.

When a plugin's download host is down

Floodgate comes from download.geysermc.org through MINECRAFT_SERVER_PLUGINS, fetched on every start, and a failed download stops the start. On 2026-10-30 that host served a Cloudflare Origin certificate that no client trusts, and on the server this template comes from every world restarted in a loop for about seven minutes. If it happens again:

  • Do not just delete the URL. The image removes from the plugins directory any entry that leaves the list (its documentation), so the jar that was working goes with it.
  • Swap the URL for a local copy. Put the same jar in ./plugins/, which is mounted at /plugins, and list its container path instead: MINECRAFT_SERVER_PLUGINS=/plugins/floodgate-spigot.jar. The list takes container paths as well as URLs. Then docker compose up -d, and switch back to the URL once the host is fixed.

Before you move to a new Minecraft version

A Minecraft release always outpaces its plugin ecosystem. VERSION defaults to LATEST, so the server takes the new one the moment it appears — and if the plugins pinned in MODRINTH_PROJECTS have no build for it yet, it exits during plugin resolution, in a loop, with its version already bumped and its backup already taken. A world converted to a version its plugins cannot load is converted either way.

tools/plugins-for-version.sh            # what LATEST resolves to today
tools/plugins-for-version.sh 1.21.4     # or a version you are considering

It reads the project list out of the compose file, so it cannot drift from what the server would actually download, and it exits 2 rather than 1 when Modrinth does not answer: a question that could not be asked is not a no. The daily verification runs it, so the answer arrives before anybody restarts anything.

Supply chain trust

This repository is a deployment template orchestrating two upstream images:

Both are pinned to tag@sha256:<digest> as interpolation defaults in the compose file's x-images block: git pull alone delivers the version combination this repository has tested. Setting an *_IMAGE_TAG variable in .env overrides the default. The daily check-pin-freshness CI job re-resolves both pinned tags against Docker Hub and compares the pinned versions against the latest itzg releases: any drift fails the run and notifies the maintainer. GitHub Actions are pinned by commit SHA; Dependabot keeps those fresh.

Two override levels exist per image. <PREFIX>_IMAGE_VERSION in .env swaps only the version of that image (Compose then pulls the tag, without a digest) and leaves every other pin as tested; <PREFIX>_IMAGE_TAG replaces the whole reference, digest included. The variable names are listed in .env.example. Nested defaults need Docker Compose v2.5 or newer (2022); v2.0 to v2.4 leave the inner ${...} unexpanded and docker compose up fails with an invalid reference instead of deploying something unexpected.

Note the deliberate trade-off: the image is pinned for reproducibility, while VERSION=LATEST floats the game version by default. Pin MINECRAFT_SERVER_VERSION too if plugin compatibility matters to you.

Verify what you deploy

Every release from v1.6.0 on carries three files made on GitHub's runner with a short-lived identity and no stored key: minecraft-server-docker-compose-<tag>.tar.gz, a git archive of exactly the tree the tag points at; minecraft-server-docker-compose-<tag>.tar.gz.sigstore.json, a keyless Sigstore signature over it; and minecraft-server-docker-compose-<tag>.intoto.jsonl, SLSA build provenance from the SLSA generator. To check them with nothing from this repository trusted:

cosign verify-blob minecraft-server-docker-compose-<tag>.tar.gz \
  --bundle minecraft-server-docker-compose-<tag>.tar.gz.sigstore.json \
  --certificate-identity-regexp '^https://github.com/heyvaldemar/minecraft-server-docker-compose/' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com

slsa-verifier verify-artifact minecraft-server-docker-compose-<tag>.tar.gz \
  --provenance-path minecraft-server-docker-compose-<tag>.intoto.jsonl \
  --source-uri github.com/heyvaldemar/minecraft-server-docker-compose

Add --source-tag <tag> for a release published after 24 September 2026, which is signed by the run that published it. The five releases before that date were signed by a run started by hand on main, so their provenance names the branch, not the tag; the archive is still the tag's tree, and the signature still belongs to this repository's workflow. The workflow that makes them is release-assets.yml.

Production checklist

  • Strong RCON password: it is remote admin access to the server console.
  • Do not expose 25575 (RCON) beyond the Docker network; the compose file does not publish it: keep it that way.
  • Set MINECRAFT_SERVER_OPS to your username(s) so you can moderate in-game.
  • Consider a whitelist (MINECRAFT_SERVER_WHITELIST) for private servers; ONLINE_MODE=true (default) keeps authentication against Mojang.
  • Off-host backups: ./minecraft-server-data-backups lives on the same disk as the world. Sync it elsewhere (restic, rclone, S3) for real disaster recovery.
  • Pin the game version before inviting players if you rely on specific plugins.

Backups

The mc-backup sidecar coordinates with the server over RCON: save-off → save-all → tar the world → save-on, on an interval (MINECRAFT_SERVER_BACKUP_INTERVAL, default 23h), pruning archives older than MINECRAFT_SERVER_PRUNE_BACKUPS_DAYS (default 7). The interval counts from the sidecar's start, so the backup window drifts to wherever the last restart put it. To pin it to the clock, set CRON_SCHEDULE in .env, for example 0 4 * * *, read in MINECRAFT_SERVER_SERVER_TIMEZONE; the interval and initial delay are then ignored. CI runs a second sidecar from the same service definition on * * * * * and requires an archive with the world in it. Archives are plain .tar.gz files in ./minecraft-server-data-backups: restore by stopping the stack, extracting an archive over ./minecraft-server-data, and starting again.

CI restores one on every run and requires the world to land at the same relative path the running server keeps it at — not merely somewhere inside the archive. An archive whose world sits a directory deeper than that unpacks cleanly, leaves the server with an empty data directory, and the server does not complain: it generates a fresh world over the one being restored.

Unattended updates

Releases are the update channel: a tag is cut only after CI has built the pinned images, booted the full stack, and passed the smoke tests. update.sh moves a deployment to the newest tag and nothing else:

./update.sh --dry-run   # show what would be applied
./update.sh             # update within the current major and redeploy

Put it on a timer for hands-off minor/patch updates:

# crontab -e
17 5 * * *  /opt/minecraft-server-docker-compose/update.sh >> /var/log/minecraft-server-update.log 2>&1

The script refuses to cross a MAJOR template version on its own. Majors are breaking by definition and their release notes exist to be read. After reading them, ./update.sh --allow-major performs the jump. It also refuses to touch a checkout with local modifications: your customization belongs in .env, which updates never overwrite.

This is deliberately a host-side script and not a container in the stack: an in-stack updater needs the Docker socket (root on the host) and turns "someone pushed to a repo" into "someone deployed to your machine" with no operator in the loop. A cron job under your own user updates only to tagged, CI-verified states and leaves the trust boundary where it was.

Resource limits

Every service carries memory and CPU limits plus reservations as compose-level defaults, the same values CI boots the stack under. Override any of them in .env (the knobs and their defaults are listed in .env.example, e.g. TRAEFIK_MEMORY_LIMIT=512m) and the override survives every git pull. If a service is OOM-killed under real load, docker inspect <container> --format '{{.State.OOMKilled}}' says so; raise its _MEMORY_LIMIT and recreate.

The server's memory limit sits a quarter above its heap, as the image's documentation asks: the kernel counts everything in the container, and the JVM keeps memory outside its heap. MINECRAFT_SERVER_MEMORY_LIMIT defaults to 2560m for the 2G heap. If you change MINECRAFT_SERVER_MEMORY, change the limit with it.

Size the heap to how much the world is played, not to be safe. The image passes MINECRAFT_SERVER_MEMORY as both -Xms and -Xmx, so the JVM commits the whole heap when it starts and, with its minimum equal to its maximum, never gives any of it back. Turn on Aikar's flags (USE_AIKAR_FLAGS: "true") and the JVM also touches every page of it before the first tick (-XX:+AlwaysPreTouch), so the container holds the full heap from the first second, whether anyone plays or not. Heap a world never uses is memory nothing else on the host can have. On the server this template comes from, ten worlds behind one proxy, all on Aikar's flags, were sized by how many people had actually come in over three months: the lobby everyone passes through has always run on 1G, and four worlds nobody had entered went from 2G to 1G. Each of those containers went from 2.7 to 1.5 GiB, still at 20.0 ticks per second. A world that lags says so in its log as "Can't keep up!"; raise that world's heap when it does, and its limit with it (1G of heap wants a limit of at least 1280m).

Container hardening

Every service runs with security_opt: no-new-privileges:true, so a process cannot gain privileges through setuid binaries even if it escapes its initial capability set. Infrastructure containers (the reverse proxy, databases, caches, backups) run with cap_drop: [ALL] and add back only what their entrypoints need: NET_BIND_SERVICE for Traefik to bind :80/:443, CHOWN/SETUID/SETGID (and friends) for database images to own their data directory and drop to their service user. Application containers keep the default capability set on purpose: upstream images assume it, and a wrong guess there is a boot loop in production rather than a hardening win. CI boots the stack under exactly these settings on every push, so what ships is what was tested.

Testing

The Deployment Verification workflow runs on every push, pull request, and every day at 06:00 UTC:

  1. Lint: actionlint on the workflow.
  2. Trivy scans of both pinned images (CRITICAL/HIGH, SARIF to the Security tab).
  3. Pin freshness (daily/manual): digest drift against Docker Hub plus release-lag checks against both itzg upstreams.
  4. Deploy-and-test: boots a real Paper server with ephemeral credentials, requires the shipped health check red the moment the server's container starts, waits for it to pass (jar + plugin download + world generation), proves RCON answers list, and requires a backup archive to appear before the run may pass. Last, it stops every process that owns TCP 25565 with SIGSTOP (found by the socket, never by a name) and requires the shipped check to fail within its timeout while the JVM is still listed, then to pass again after SIGCONT; the stop stays short of Paper's 60-second watchdog.

A green run is the authoritative proof that the shipped configuration produces a joinable server, not just a started container.


About the maintainer

Maintained by Vladimir Mikhalev · Docker Captain · CNCF Ambassador · IBM Champion · AWS Community Builder

YouTube · Blog · LinkedIn