Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
CraftDock
Lightweight, self-hosted web panel to manage a single Minecraft server (Java or Bedrock) running as a Docker container — an Aternos-style dashboard you host yourself. Built for a Docker/Coolify deployment.
The panel never modifies the Minecraft core. It controls the game container over the Docker socket (start/stop/restart/kill, stats, logs) and sends commands through RCON (Java) or container stdin (Bedrock).
Features
| Tab | What it does |
|---|---|
| Início / Status | Start / Stop / Restart / Kill, live CPU / RAM / player count, edition badge |
| Console | Real-time log stream over WebSocket + command input (RCON / stdin) |
| Jogadores | Persistent world player directory (paginated, searchable), per-player inspector modal (position, health, food, inventory, XP, gamemode — read straight from the world save), whitelist / ban / op management, kick & teleport — controls adapt to what the edition supports |
| Opções | Friendly editor for server.properties (difficulty, PvP, max-players, gamemode, …) — keys missing from the file are pre-filled with their vanilla defaults |
| Mundo | Download the world as .zip, upload/replace a world, regenerate a new world |
| Mapa | Shows the world seed and embeds a seed map (mcseedmap.net) rendered for that seed |
Editions
CraftDock supports both Minecraft editions through an adapter layer, but they are not equivalent:
- Java — commands go over RCON. All player controls work: whitelist, ban/pardon, op/deop, give, gamemode, kick, teleport.
- Bedrock — commands go over the container's stdin (
docker attach).whitelistbecomesallowlist, and ban / pardon have no native command — those buttons are hidden in the UI.
The edition is detected from the container's TYPE env var when MC_EDITION=auto, or forced with MC_EDITION=java|bedrock.
Player inspector (world-save reads)
The Players tab keeps a persistent directory of everyone who ever spawned in the world and can open a per-player detail modal with position, health, food, inventory, XP and gamemode. All of it is read directly from the world save, read-only — never via console commands:
- Java —
world/playerdata/<uuid>.dat(names seeded fromusercache.json). - Bedrock — the world LevelDB. Bedrock never stores gamertags in the DB, so CraftDock builds the identity chain itself: XUID+name from
Player Spawnedlog lines (craftdock_players.json), then — the first time you open an online player —querytargetyields the player'suniqueIdUUID, which keys the DB'splayer_<uuid>mapping record pointing to the realplayer_server_<uuid>data record. The binding is persisted (craftdock_bedrock_ids.json), so offline reads work forever after.
While the server is running, reads happen inside a save snapshot (save hold/save query/save resume on Bedrock, save-all flush on Java) so files are consistent.
Live map & seed
The Map tab shows the world seed and embeds mcseedmap.net pointed at it (a real biome/structure map that allows iframe embedding). The seed is resolved automatically:
level-seedfromserver.properties(when the operator set a fixed seed), else- Bedrock — read from
worlds/<level-name>/level.dat(theRandomSeedNBT long). Bedrock has noseedconsole command, so this is the only reliable source. - Java — the
seedcommand over RCON.
The mcseedmap version segment defaults per edition; override it with MAP_VERSION (e.g. 1.21-Java, 26.30.0-Bedrock) if the biomes don't match your server version.
Logging
The panel emits structured JSON logs (one record per line: ts, level, component, msg, optional meta) to stdout. Level is controlled by LOG_LEVEL (error < warn < info < debug; default info). Command channels (RCON / stdin), seed resolution, HTTP requests, and unhandled errors are all logged — run with LOG_LEVEL=debug to see every command sent to the server and its response.
docker compose -f docker-compose.bedrock.yaml logs -f # follow panel + server logs
Requirements
- Node.js ≥ 18 (dev; the image uses Node 22) or Docker (deploy).
- A Docker host — the panel mounts
/var/run/docker.sockto control the game container.
Quick start (local development)
git clone <repo> && cd CraftDock
npm install
# 1. Generate the admin password hash (prints a raw and a base64 line):
npm run hash -- <your-password>
# 2. Create .env from the template and fill it in:
cp .env.example .env
# - ADMIN_PASSWORD_HASH = the raw hash line from step 1 (best for `npm run dev`)
# - SESSION_SECRET = any long random string
# - RCON_PASSWORD = your server's RCON password (leave empty for Bedrock)
# 3. Run the tests and start the dev server:
npm test
npm run dev # loads .env automatically, hot-reloads on change
Open http://localhost:3000 and log in with your password.
npm run devusesnode --env-file=.env, so your.envis loaded automatically.npm start(production) does not load.env— it reads real environment variables provided by the container/host.
Running with Docker
The panel image is built from the included Dockerfile. Two ways to run each stack:
- Build it yourself (
docker-compose.<edition>.yaml,--build): works anywhere, no registry needed. - Pull the published image (
docker-compose.<edition>.registry.yaml):git.lucasrguerra.dev.br/lucasrguerra/craftdock(linux/arm64), built, scanned and smoke-tested by CI. Tags:latest,X.Y.Z,X.Yandsha-<commit>; pin one withIMAGE_TAG. This is what the reference Coolify deployment runs.
To build it:
Two reference stacks are provided:
Java (docker-compose.java.yaml)
# secrets are read from .env by docker compose (${ADMIN_PASSWORD_HASH}, etc.)
docker compose -f docker-compose.java.yaml up -d --build
docker compose -f docker-compose.java.yaml logs -f
Bedrock (docker-compose.bedrock.yaml)
docker compose -f docker-compose.bedrock.yaml up -d --build
docker compose -f docker-compose.bedrock.yaml logs -f
Both stacks share the same panel env block (only the edition, world folder and
Minecraft image differ) and read their values from .env — see .env.example
for the canonical list.
The panel only
exposes port 3000 (reachable over the Docker network) — it is not published on the host by default, because the composes target a reverse proxy (Coolify/Traefik terminates TLS on 443 and forwards to 3000). In Coolify set the service domain without a port (https://your.domain, not:3000) and make sure the service's target port is 3000 — a mismatch (e.g. Traefik still forwarding to an old port) makes plain 443 return "no available server". For local testing without a proxy, comment outexposeand uncomment theports:line in the panel service, setNODE_ENV=developmentin.env(so the secure cookie isn't dropped over plain HTTP), then open http://localhost:3000.
Rebuild after changing panel code:
--buildis required the first time and whenever you change the panel source. A runtime-only change (env var) just needsup -d --force-recreate.
First Bedrock boot is slow —
itzg/minecraft-bedrock-serverdownloads the latest version. Watch the logs until you see the server report it has started.
Deployment prerequisites (Coolify / production)
The panel controls an existing, fixed container — it never recreates it or changes TYPE/VERSION. For everything to work, the Minecraft service must be configured as follows:
stdin_open: trueandtty: trueon the Minecraft service. Without a TTY, Docker returns a multiplexed log stream (8-byte frame headers) that garbles the Console tab, and stdin (the Bedrock command channel) never reaches the server process.OVERRIDE_SERVER_PROPERTIES=false, otherwise theitzgimage regeneratesserver.propertiesfrom env vars on every start and the Options tab won't persist.- Java only:
ENABLE_RCON=true+ a sharedRCON_PASSWORD. - Panel and Minecraft server on the same Docker network.
- The
mc-datavolume shared between the panel and the Minecraft server (the panel reads/writes world files directly). - Set
ADMIN_PASSWORD_HASH_B64(the base64 line fromnpm run hash) and a strongSESSION_SECRET. Prefer the base64 form here — a raw bcrypt hash's$chars get mangled by docker compose / Coolify interpolation. - Run the panel with
NODE_ENV=productionso the session cookie is markedsecure— this requires serving the panel over HTTPS (Coolify/Traefik terminate TLS on 443). The app already callstrust proxy, so the secure cookie works correctly behind the reverse proxy. - (Optional) Set
MAP_VERSIONif the Map tab's mcseedmap version doesn't match your server.
Environment variables
| Variable | Required | Default | Description |
|---|---|---|---|
ADMIN_PASSWORD_HASH |
✅¹ | — | Raw bcrypt hash (npm run hash -- <pw>). Best for npm run dev |
ADMIN_PASSWORD_HASH_B64 |
✅¹ | — | Base64 of the bcrypt hash. Best for docker compose / Coolify (no $ escaping) |
SESSION_SECRET |
✅ | — | Long random string signing the session cookie |
MC_CONTAINER_NAME |
craftdock-mc-server |
Minecraft container name (matched first) | |
MC_SERVICE_NAME |
minecraft-server |
Compose service name — fallback match when the container name is auto-generated (e.g. Coolify) | |
MC_DATA_PATH |
/minecraft/data |
Path (inside the panel) to the shared MC data volume | |
MC_EDITION |
auto |
auto | java | bedrock |
|
MC_WORLD_NAME |
world |
World folder under MC_DATA_PATH (Bedrock: worlds) |
|
RCON_HOST |
craftdock-mc-server |
RCON host (Java) | |
RCON_PORT |
25575 |
RCON port (Java) | |
RCON_PASSWORD |
(empty) | RCON password — required for Java, leave empty for Bedrock | |
MAP_VERSION |
(per edition) | mcseedmap version segment override (e.g. 1.21-Java, 26.30.0-Bedrock) |
|
PORT |
3000 |
Port the panel listens on (also the exposed/target port in the composes) | |
NODE_ENV |
development |
production marks the session cookie secure (needs HTTPS) |
|
LOG_LEVEL |
info |
error | warn | info | debug |
|
MAX_UPLOAD_MB |
1024 |
Max world upload size (MB) |
¹ Provide one of ADMIN_PASSWORD_HASH or ADMIN_PASSWORD_HASH_B64. npm run hash -- <pw> prints both; the raw hash wins if both are set.
Security notes
- The panel mounts the Docker socket, which is equivalent to root on the host. Never expose it unauthenticated. Access is gated by a single admin password (bcrypt) with a signed session, enforced on every
/api/*route and the Socket.io handshake. - Always serve the panel over HTTPS in production (secure session cookie).
- Keep
SESSION_SECRETsecret and unique per deployment.
Troubleshooting
pull access denied for craftdock-panel on docker compose up
The panel image is built locally, not pulled. Add --build: docker compose up -d --build.
failed to bind host port 0.0.0.0:3000: address already in use
Something else is on port 3000 — usually a leftover npm run dev. Find and stop it:
Get-NetTCPConnection -LocalPort 3000 -State Listen # find the PID
Stop-Process -Id <PID> -Force # stop it
Then bring the stack up again.
ERR_CONNECTION_REFUSED even though the panel logs "listening on :3000"
The container is listening internally but the host port wasn't published — typically a container that was created during a failed port-bind and then only restarted. Recreate it:
docker compose -f <compose-file> down
docker compose -f <compose-file> up -d --force-recreate
Confirm the host is listening: Get-NetTCPConnection -LocalPort 3000 -State Listen.
Login succeeds (no error) but the page stays on the login screen
The session cookie isn't being stored. It's marked secure (from NODE_ENV=production) while you're on plain http://localhost — browsers drop secure cookies over HTTP. For local HTTP testing set NODE_ENV=development; in production serve over HTTPS. (Behind a reverse proxy, the app already trusts X-Forwarded-Proto via trust proxy.)
Console tab shows garbled/binary characters
The Minecraft container is missing stdin_open: true + tty: true. Add both and recreate it.
Options tab changes don't persist after restart
The Minecraft service is missing OVERRIDE_SERVER_PROPERTIES=false.
Coolify: Bind for 0.0.0.0:3000 failed: port is already allocated
Don't publish the panel's port on the host under Coolify — its Traefik reaches the container over the network. The composes already ship with expose: ["3000"] and the ports: line commented out (this is the default); keep it that way and set the service domain in Coolify. Keep ports: only on the Minecraft service (the raw game port).
Coolify: no available server when opening https://your.domain/
Traefik has a route for the domain but is forwarding to the wrong container port, so the backend looks empty. Two things to check: (1) the Coolify domain must be set without a port (https://your.domain, not :3000); (2) the service's target port must be 3000 — the panel listens on 3000. A common trap is an old target port left over from a previous setup (e.g. Traefik still pointing at a port the app no longer listens on): align it to 3000 and redeploy.
Coolify / docker compose: login fails, or warning The "…" variable is not set. Defaulting to a blank string.
The … is the tail of your bcrypt hash — bcrypt hashes contain $, which docker compose interprets as variable interpolation, corrupting the hash. Use ADMIN_PASSWORD_HASH_B64 (the base64 line from npm run hash) instead of the raw ADMIN_PASSWORD_HASH — base64 has no $, so nothing gets mangled. (Alternatively, keep the raw hash but escape every $ as $$.)
Status shows not_found on Coolify (panel can't see the server)
Coolify ignores container_name and auto-generates names like minecraft-server-<project>-<hash>, so matching by MC_CONTAINER_NAME fails. The panel falls back to the compose service name — make sure the Minecraft service is named minecraft-server (or set MC_SERVICE_NAME to match) and that both services are in the same Coolify resource (project). The panel also needs the Docker socket mounted (/var/run/docker.sock).
Map tab shows "waiting for seed" / no map
The seed is resolved only while the server is running. For Bedrock it comes from worlds/<level-name>/level.dat, so the mc-data volume must be shared with the panel.
Development
npm test # run the full Vitest suite
npm run test:watch
npm run dev # start with --env-file=.env and --watch
Project layout
src/
├── server.js # Express + Socket.io composition
├── main.js # composition root (wires real dependencies)
├── config.js # env var loading + validation
├── logger.js # structured JSON logger
├── healthcheck.js # container healthcheck script
├── appState.js # cached edition/adapter resolution
├── middleware/ # auth (HTTP + socket handshake)
├── services/ # docker, rcon, stdin, properties, world, auth, seed,
│ # playerDirectory, playerDetailService, bedrockIdentityBridge
├── adapters/ # ServerAdapter: java (RCON) / bedrock (stdin)
│ └── playerFile/ # read-only world-save readers (Java NBT / Bedrock LevelDB)
├── routes/ # auth, status, players, properties, world
├── sockets/ # logs stream + status push namespaces
└── public/ # vanilla-JS tabbed SPA (Tailwind via CDN)
CraftDock also keeps two metadata files at the data root (bundled into world backups): craftdock_players.json (persistent XUID→name player directory) and craftdock_bedrock_ids.json (Bedrock identity bridge). See CLAUDE.md for the full architecture and contribution conventions (numbered feature branches, #N: commit prefix).
Continuous integration and releases
CI runs on the Gitea runners (.gitea/workflows):
ci.yml(branches and every Monday): Trivy on the repository (vulnerabilities, secrets, misconfiguration; the acceptedDS-0002is documented in.trivyignore), the Vitest suite inside the Dockerfileteststage, the arm64 image build, Trivy on the image and a smoke test (scripts/smoke-imagem.sh: UI served, API refuses unauthenticated requests, item icons generated, LevelDB addon loads, image healthcheck turns healthy).release.yml(mainandvX.Y.Ztags): if the registry already holds the image for the same content (inputs-<hash>), nothing is rebuilt — only the new tags are applied. On a tag it creates the Gitea release with the CHANGELOG notes.- To release: update the CHANGELOG and
package.json, commit, thengit tag vX.Y.Z && git push gitea vX.Y.Z.