Define what needs protection
A public Minecraft port exposes the server application to the internet. Plugins and mods execute code inside that application. Docker and SSH administer the host beneath it. Treat these as separate boundaries:
- player authentication and allow-listing
- Minecraft/RCON network exposure
- plugin and mod supply chain
- container privileges and host mounts
- host administration, patching, and recovery
No single Compose flag covers all five.
A hardened Compose baseline
This example assumes ./data is owned by UID/GID 1000:1000:
services:
mc:
image: itzg/minecraft-server:java25
restart: unless-stopped
user: "1000:1000"
read_only: true
tmpfs:
- /tmp:size=256m
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
ports:
- "25565:25565"
environment:
EULA: "TRUE"
TYPE: PAPER
VERSION: "26.1.2"
ONLINE_MODE: "TRUE"
ENABLE_WHITELIST: "TRUE"
RCON_PASSWORD_FILE: /run/secrets/rcon_password
volumes:
- ./data:/data
secrets:
- rcon_password
secrets:
rcon_password:
file: ./rcon_password.secret
Create a long random RCON password in rcon_password.secret, restrict that host file, and exclude it from Git. Compose mounts it read-only at /run/secrets/rcon_password.
The writable server state stays under /data; the image root filesystem is read-only and /tmp is temporary. The explicit non-root user lets the container drop all Linux capabilities without relying on root during startup.
Validate the security controls
Before starting, inspect the fully resolved Compose file. Docker treats Compose as trusted host instructions, including nested includes and bind mounts:
docker compose config
docker compose up -d mc
docker compose exec mc id
docker port "$(docker compose ps -q mc)"
docker inspect "$(docker compose ps -q mc)" --format 'readonly={{.HostConfig.ReadonlyRootfs}} user={{json .Config.User}} caps={{json .HostConfig.CapDrop}} security={{json .HostConfig.SecurityOpt}}'
Expected results:
- the process reports UID/GID 1000
- only Minecraft port 25565 is published
- RCON 25575 is not mapped to the host
- root filesystem is read-only
- all capabilities are dropped and
no-new-privilegesis active
Then verify the application still works:
docker compose exec mc rcon-cli version
docker compose exec mc rcon-cli whitelist add your-minecraft-name
docker compose exec mc rcon-cli whitelist list
docker compose exec mc rcon-cli save-all flush
Replace your-minecraft-name with the real username before running the second command.
Keep authentication enabled
Use ONLINE_MODE: "TRUE" for a directly exposed Java server so Mojang/Microsoft account authentication remains active. A whitelist reduces who can join but does not replace online-mode authentication.
Offline mode belongs only behind a correctly secured proxy design that performs authentication and prevents direct backend access. It is not a shortcut for public servers.
Publish only required ports
For a normal Java server, publish TCP 25565. Do not publish these merely because they exist inside the container:
- RCON 25575
- JMX 7091
- arbitrary query or plugin ports
- the Docker socket
Run administrative commands with docker compose exec mc rcon-cli .... Apply both the host firewall and, on Hetzner, the Cloud Firewall as described in the port 25565 guide.
Treat plugins, mods, and Compose files as code
Paper warns that plugins receive unrestricted access to the server and machine from the process perspective. Install only signed or checksummed releases from trusted project pages, pin versions, and keep an inventory.
Review third-party Compose files before running them. Be especially suspicious of:
privileged: truenetwork_mode: hostorpid: hostcap_add, especiallySYS_ADMIN- host devices and broad bind mounts
/var/run/docker.sock- remote
includeorextendscontent you did not inspect
Use the Paper plugin runbook for a controlled lifecycle.
Secure the host and recovery path
Container hardening cannot repair an exposed SSH account or an unpatched Docker daemon:
- use SSH keys and a non-root administrative account
- install OS and Docker security updates
- restrict who belongs to the
dockergroup; Docker access is effectively host-root access - keep the host firewall narrow
- send backups off-host and test restores
- keep secrets out of Git, shell history, and screenshots
Backups writable from the same compromised host can be deleted too. Maintain a second failure domain with automatic backups and off-host retention.
Update without losing control
Pull image security fixes regularly, but do not combine an unattended Minecraft version change with the image update. Pin VERSION, back up, pull the Java image, and follow the safe update and rollback runbook.
If you adopt Docker rootless mode, follow Docker's official prerequisites and re-test port publishing, bind-mount ownership, cgroups, and service startup. Rootless reduces daemon/runtime privilege but is a host-level migration, not a single Compose option.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
/data permission denied | Host directory not owned by UID/GID 1000 | Correct ownership; do not add capabilities |
Startup cannot write /tmp | Missing tmpfs with read-only root | Add the /tmp tmpfs shown above |
| RCON authentication fails | Secret missing, unreadable, or changed | Grant the secret to mc and recreate the service |
| Players cannot join | Whitelist is empty or firewall is closed | Add real usernames and verify only port 25565 |
| Plugin asks for broad host access | Unsafe design or untrusted instructions | Do not add mounts/privileges until independently justified |
Next steps
- Verify internet exposure with the Hetzner and Linux firewall guide.
- Prepare recovery with automatic Docker Minecraft backups.
- Investigate instability with the container restart-loop runbook.