A self-hosted Bedrock Dedicated Server in Docker, exposed to the internet through a playit.gg tunnel (no port-forwarding, works for consoles), wrapped in a batteries-included operations layer:
- ๐ฒ Notifications โ phone push (ntfy) on every join/leave; optional macOS desktop alerts
- ๐ Monitoring โ crash / tunnel-down / high-CPU-mem alerts, a daily digest, and a 4-panel uptime graph
- ๐ฌ Two-way Discord chat โ in-game chat appears in
#in-game-chat, and Discord messages appear in-game - ๐ฎ Commands anywhere โ
!cmdin-game and/cmd(or!cmd) in Discord share one rate-limited surface - ๐บ๏ธ World map โ render the overworld to a hill-shaded PNG and post it to
#map - ๐๏ธ Backups โ consistent world snapshots, self-rotating, with one command
- ๐ฉบ Health check โ
just doctor(also!doctor//doctor) for an at-a-glance status report - โป๏ธ Self-healing โ a watchdog auto-restarts the server if it goes unhealthy;
!restart//restart <reason>forces it by hand any time (warns online players first, logs who & why, rate-limited per person) - ๐ก๏ธ Safety guards โ nothing restarts/stops the server while a player is online
- ๐ง
just setupโ an interactive wizard that gets a fresh clone running
Runs on an Apple-Silicon Mac (the x86 Bedrock server runs under box64). The server stack is Docker; a few host-side helpers run as macOS launchd agents โ see Architecture.
Version: date-based (CalVer
YYYY.MM.DD) โ current inVERSION, history inCHANGELOG.md.
| Tool | Needed for | Install |
|---|---|---|
| Docker (+ daemon running) | the server stack | OrbStack or Docker Desktop |
| just | the task runner | brew install just |
| Go | the just setup wizard only |
brew install go (or go.dev/dl) |
| python3 | chat bot + uptime graph (optional features) | brew install python3 |
| terminal-notifier | macOS desktop alerts (opt-in only) | brew install terminal-notifier |
git clone <this-repo> && cd minecraft-server
just setupjust setup launches an interactive, idempotent wizard that:
- Checks your machine for the tools above and prints install hints for anything missing.
- Walks you through
.envone prompt at a time (pre-filled from any existing.envโ accept to keep, or edit). Only playit's secret key is required; everything else is optional and skippable. - Writes
.env(creating it from.env.exampleon a fresh clone, or merging in place without clobbering existing values/comments). - Offers to start the stack and install the background agents โ each step is safe to re-run, and anything already done is marked as such.
Re-run just setup any time; it never breaks what's already configured or running.
Don't want the wizard?
cp .env.example .env, fill it in, and rundocker compose up -d. See Manual setup.
just tunnel # prints your playit address, e.g. <random>.ply.gg:12345Give that host + port to your friends. See Connecting.
Two homes, by design (see why below):
| Service | Image | Job |
|---|---|---|
| bedrock | itzg/minecraft-bedrock-server | the game server |
| playit | playit-agent | outbound tunnel โ public address |
| monitor | docker:cli | reads logs/stats, fires health alerts, writes CSVs (mounts the docker socket read-only) |
| bridge | curlimages/curl | subscribes to the ntfy bus, fans events out to Discord webhooks |
| autoheal | willfarrell/autoheal | restarts bedrock if its healthcheck goes unhealthy (mounts the docker socket read-write โ must issue restart) |
| Agent | Manage with | Job |
|---|---|---|
com.mcserver.notify |
just notify-* |
join/leave โ phone push (+ opt-in desktop alert) |
com.mcserver.uptime |
just uptime-* |
daily uptime graph โ #monitoring |
com.mcserver.chatbot |
just bot-* |
Discord #in-game-chat โ in-game chat |
Why some things run on the host (not in containers):
- macOS desktop notifications can't be sent from a Linux container โ that's a host-only API.
- The chat bot drives
docker exec โฆ send-command(read-write daemon control). A writable docker socket inside a container is root-equivalent on the host; running the controller outside what it controls is safer and avoids a lifecycle paradox. - Host agents keep observing/announcing even while the stack is down or being recreated.
(The monitor container is the exception that proves the rule: it only reads the daemon, so it
mounts the socket read-only and is safely containerized. The autoheal container is the one
deliberate read-write exception โ it does exactly one thing, docker restart an unhealthy
container, which is why it's a tiny purpose-built image rather than something with shell access.)
Addresses below are examples โ get yours with
just tunnel.
Play โ Servers tab โ Add Server โ name it, enter the tunnel host + port โ Save โ Join.
Consoles have no "Add Server", so they use a DNS redirect into the featured-server list:
- Console network settings โ set DNS to Manual:
- Primary:
45.55.68.52(Xbox/Switch can use104.238.130.180) - Secondary:
8.8.8.8
- Primary:
- Launch Minecraft โ Play โ Servers โ open any featured server โ BedrockConnect loads.
- Connect to a Server โ enter your tunnel host + port โ check Add to server list โ connect.
- When done, set console DNS back to Automatic.
Quick check: phone on mobile data (Wi-Fi off) โ Add Server โ tunnel host+port. If the phone connects, the full internet path works and consoles will too.
just # list every recipe
just up # start (guarded; announces return to #server-status with ๐ข)
just down "reason" # stop (guarded; kicks players cleanly first; announces ๐ ๏ธ)
just restart # bounce bedrock only (keeps logs)
just status # container status just players # who's online
just tunnel # public address just logs # follow all logs
just backup # consistent world snapshot (auto-rotates oldest)
just doctor # one-shot health report just map # render the overworld โ world-map.png
just tp Steve Alex # teleport a player to a player or "x y z" โ mount-aware (horse/boat/โฆ comes along)Personal per-machine helpers can live in scripts/.local.just โ it's gitignored and
auto-imported when present, so they show up in just --list without touching the shared repo.
Timestamps everywhere are UK time (TZ=Europe/London), matching Discord and the graphs.
just up/down/restart prompt for a #server-status message (Enter for a default); the
announcement is prefixed ๐ข (up) or ๐ ๏ธ (down).
scripts/notify.sh (the notify agent) tails the server log and announces joins/leaves. The two
output channels are independent:
- Phone push via ntfy.sh โ the default. On whenever
NTFY_TOPICis set. - macOS desktop alert โ opt-in, off by default. Enable with
NOTIFY_MACOS=1in.env(usesterminal-notifier).
just notify install|uninstall|status # manage the permanent agent
just notify test # prove the pipeline without logging inNTFY_TOPIC doubles as a pub/sub bus: publishers POST events, and subscribers (your phone's
ntfy app, the bridge container โ Discord) react.
The monitor container detects and publishes: server down/up, the box64
heap-corruption crash family (invalid next size / corrupted size vs / double free),
tunnel-down (playit heartbeat silence), high CPU/memory, and a daily digest. Thresholds
via .env (MONITOR_CPU_ALERT %, MONITOR_MEM_ALERT MiB, MONITOR_ALERT_COOLDOWN s).
The bridge routes each event to a per-channel Discord webhook by category:
| Category | Events | Webhook (.env) โ channel |
|---|---|---|
player |
joins / leaves | DISCORD_WEBHOOK_PLAYER โ #player-activity |
alert |
crash, up/down, tunnel, CPU/mem | DISCORD_WEBHOOK_SERVER_STATUS โ #server-status |
monitor |
daily digest, uptime graph | DISCORD_WEBHOOK_MONITOR โ #monitoring |
chat |
in-game chat relay | DISCORD_WEBHOOK_CHAT โ #in-game-chat |
Any category left unset falls back to DISCORD_WEBHOOK_URL. Reload the bridge after editing webhooks
(never touches bedrock): docker compose up -d --no-deps bridge.
A 4-panel daily PNG (status strip ยท players ยท CPU ยท memory) in UK time โ #monitoring. The status
strip is driven by healthchecks.io flip history (so it reflects real player-reachability, not just
"is the container up"). Needs HEALTHCHECK_URL, HEALTHCHECK_API_KEY, DISCORD_WEBHOOK_MONITOR.
just uptime # post the last 24h now
just uptime-preview # render locally WITHOUT posting
just uptime-agent install # schedule the daily 00:05 post (launchd)The daily post lands at 00:05 Europe/London wherever the host machine is. launchd can't be
told a timezone โ StartCalendarInterval fires on the Mac's local clock, and launchd fixes that
offset when the job is bootstrapped rather than re-reading it when the machine's timezone changes.
So the agent ticks every 15 minutes instead and scripts/uptime-tick.sh decides, in London time,
whether the post is due. If the Mac is asleep at UK midnight the digest goes out on the first tick
after it wakes rather than being skipped.
-
In-game โ Discord: a Bedrock behavior pack (
bedrock-data/behavior_packs/chat-bridge) posts chat to#in-game-chatviaDISCORD_WEBHOOK_CHAT. (Advanced: needs the world's "Beta APIs" experiment enabled + the pack added to the world โ already configured on the bundled world.) -
Discord โ in-game: the chat bot (
com.mcserver.chatbot) listens on the Discord Gateway and injects messages withtellraw. Loop-safe: it only relays human messages from the one channel (ignores bots/webhooks), andtellrawnever re-triggers the outbound relay.# .env: CHAT_BOT_TOKEN + IN_GAME_CHAT_CHANNEL_ID (bot needs MESSAGE CONTENT INTENT) just bot run # foreground test just bot install # permanent agent just bot logs|status|uninstall
-
From the console:
just say "dinner in 10" # broadcast to everyone (also โ #in-game-chat) just say-raw "ยง6ยงlHeads up!ยงr ยง7dinner in 10" # styled broadcast (ยง colour codes) just tell Steve "your base is on fire" # private whisper (not relayed)
just backup # flush + consistent snapshot (no player disconnect) โ #backups
just backups # list (๐ = saved/exempt) just restore <name> # restore one
just backup-save <name> / backup-unsave <name> # protect a snapshot from rotationRotation is automatic. After each just backup, the newest BACKUP_KEEP
(.env, default 10) unsaved snapshots are kept and older ones pruned.
Mark any snapshot with just backup-save <name> to keep it forever โ ๐ saved
snapshots are exempt from rotation and don't count toward the limit.
Players can also trigger a backup themselves via !backup (in-game or Discord),
rate-limited to one per requester every BACKUP_COOLDOWN (default 10 min).
just doctor # full report: containers, health, restart counts, CPU/mem, disk, webhooks
just doctor --brief # one compact line (what the in-game !doctor replies with)
just doctor --discord # digest-style Markdown report with a last-hour glance (what Discord !doctor / /doctor reply with)Runnable from in-game (!doctor, brief one-liner) and Discord (!doctor or /doctor โ
a digest-style report: traffic-light status, uptime, who's online, CPU/RAM sparklines for
the last hour, joins/leaves, tunnel blips, command usage, crashes, disk and last backup).
Every chat-command use (in-game ! or Discord !//) is recorded to
bedrock-data/monitoring/commands.csv (time,source,who,command) โ Discord uses are
appended by the chat bot, in-game uses are scraped from the pack's CMDLOG log lines by
the monitor. The Discord doctor shows the last hour's uses and the daily digest shows a
per-person "who ran what, when" breakdown for the day.
The x86 Bedrock server runs under box64, which occasionally hits a heap-corruption
crash. Sometimes the process dies but its wrapper keeps the container's PID 1 alive โ
so the container stays running while the game is dead, and Docker's
restart: unless-stopped policy (which only fires when a container exits) never
kicks in. Two layers cover this:
- Automatic โ the
autohealwatchdog. bedrock's healthcheck pings the live game port; after ~90s of failed pings (3 misses) the container goesunhealthyandautohealdocker restarts it. A generousstart_period(180s) keeps a slow world-load instarting, notunhealthy, so a normal boot is never restarted, andjust downremoves the container so the watchdog never fights maintenance. - Manual โ
!restart <reason>//restart(Discord). Forces a restart any time โ including a server that looks healthy but is wedged (e.g. players show in/playersbut can't get past the loading screen). It's safe by construction: when players are online it runs the in-game 60sโ10s countdown (and pings#server-status) before bouncing, it logs who asked and why tobedrock-data/restart.log, and a per-requester cooldown (RESTART_COOLDOWN, default 600s / 10m) stops one person bounce-looping. A reason is required on Discord. Discord-only by design: a crashed server has no in-game chat to type into.
One command surface, two front-ends. In-game chat uses the ! prefix; Discord
accepts the same ! prefix and native / slash commands (in #in-game-chat
or #map). Because there's no clean DiscordโMinecraft identity mapping,
player-specific commands take the target as an argument in Discord.
| Command | Does | Notes |
|---|---|---|
!commands (alias !help) |
list available commands | |
!players (alias !online) |
list who's online right now | in-game reads the live player list; Discord queries the server |
!coords [player] |
report a player's coordinates | pass the target in Discord |
!backup |
trigger a world snapshot | rate-limited per requester (BACKUP_COOLDOWN) |
!map |
render the overworld โ #map |
rate-limited per requester (MAP_COOLDOWN) |
!doctor |
health report | in-game replies with the --brief one-liner; Discord gets a digest-style report with a last-hour glance |
!restart <reason> |
restart the server (any time) | Discord only โ reason required; warns online players (60s countdown) + logs who/why; rate-limited per requester (RESTART_COOLDOWN, default 10m) |
!mail <player> <msg> |
leave in-game mail | delivered on the recipient's next join |
!sleep |
skip the night to morning | in-game only โ forces the skip even when players are AFK; only works after dusk; also clears storms |
!shrug |
ยฏ\_(ใ)_/ยฏ |
Slash equivalents: /commands (alias /help), /players (alias /online), /map, /backup, /doctor, /restart, /coords, /mail, /shrug.
!restart//restartis Discord-only by design โ a crashed server has no in-game chat to type into, so the recovery command lives where it's reachable when the game is down. A reason is required and logged. The autoheal watchdog handles the automatic case; this is the manual override when the server is wedged but not yet flaggedunhealthy.
The in-game
!commands live in the chat-bridge behavior pack and activate on the next server reload; the Discord side is served by thechatbotagent.
just map reads the live world's LevelDB (in a throwaway mc-tools container),
extracts a surface heightmap, and renders a north-up, hill-shaded terrain PNG
(world-map.png, gitignored โ it's a render of the real world). !map / /map
post it directly to #map via DISCORD_WEBHOOK_MAP.
Live renders also overlay players: a green marker + gamertag + live coordinates for everyone currently online (positions queried from the running server), plus a red "last seen" marker for offline players at their saved position โ named via the gitignored gamertagโServerId map (unmapped players get an opaque short id, since the world DB stores no gamertags). Rendering a backup db skips the overlay.
just map # render the live world
just map "bedrock-data/backups/<name>/db" # render a specific backup (guaranteed-consistent)- Interactive guard (
scripts/guard.sh) runs beforeup/down/restart/recreate. If players are online it lists them and makes you type the exact count to proceed. Bypass for automation:FORCE=1. - Claude Code hook (
.claude/) intercepts Claude's own disruptive commands and prompts you first. - Optional shutdown countdown โ set
SHUTDOWN_COUNTDOWN=60(seconds) in.envanddown/restart/recreatewill warn online players in-game every 10s (60s, 50s, โฆ 10s) before stopping. Off by default; skipped when nobody's online. One-off:SHUTDOWN_COUNTDOWN=60 just restart "patch incoming".
Read-only commands and monitor/bridge-only ops are never gated.
cp .env.example .envand fill it in (onlyPLAYIT_SECRET_KEYis required). Get a playit key: sign up at playit.gg โ New agent (Docker) โ copy the secret (shown once).docker compose up -dโ then confirm the tunnel:docker compose logs -f playit(wantsecret key validโagent registeredโtunnel running).- Create the tunnel at playit.gg/account/tunnels โ New Tunnel โ
Minecraft Bedrock โ Free Network โ assign your agent โ Origin: Local Address
172.20.0.10, Port19132(it defaults to127.0.0.1, which is wrong โ the server is a separate container). - Optional features: set the relevant
.envvars and install the agents (just notify install,just uptime-agent install,just bot install).
The world persists in ./bedrock-data (a bind mount) and survives docker compose down.
To wipe it: docker compose down && rm -rf ./bedrock-data.
just cmd "gamerule showcoordinates true"
just cmd "execute as @a at @s run setblock ~ ~5 ~ minecraft:diamond_block"Note on crashes: the box64 heap-corruption crashes (
free(): invalid next size, etc.) are an emulation artifact of running x86 Bedrock on Apple Silicon. Restarts clear them; the real cure is native x86 hosting. The monitor detects and reports them.