GitHub, for Minecraft worlds. A self-hostable platform that hosts mcagit
repositories (version-controlled worlds) over HTTP and gives them a web face: push a world, browse its
backup timeline, and see the semantic diff of any backup — which chunks, blocks, and entities
changed, and where the griefing happened — none of which a generic git host can show.
If mcagit is git for worlds, this is the hub you push them to.
- Hosts worlds over HTTP —
mcagit clone | fetch | push http://<hub>/r/<name>works against it. Pushing to a new name auto-creates the world. - Web UI — a repo list, each world's branches + backup timeline, a backup view that combines the file/chunk/block diff with a "what happened here" grief summary (destroyed / placed / replaced, the destruction bounding box, the most-destroyed blocks), a compare-any-two-backups view (the same forensics between arbitrary backups), a world explorer (players + find an entity / block entity / sign), a rendered top-down map per backup (with a before/after pair on the compare page), and a time-machine scrubber that slides the map across a world's whole backup history.
- Powered by the Rust
mcagitengine — the C# web layer drives themcagitbinary out-of-process: it reverse-proxies the transport to a co-locatedmcagit servesidecar, and renders the web UI frommcagit diff/where-changed/players/find --json+mcagit render. No in-process .NET core; worlds are stored in mcagit's native (blake3/zstd) object format. - Accounts (optional) — OAuth sign-in (GitHub by default, any OAuth2 provider via config), per-user personal access tokens for the CLI, public/private worlds, and sharing via per-repo collaborators (read/write) and teams (grant a whole group at once). Off by default; the hub stays a zero-config local tool until you hand it an OAuth app.
The quickest way:
docker compose up # serves http://localhost:5080(or pull the published image directly: docker run -p 5080:5080 -v mcahub:/data ghcr.io/bangrocket/mcahub).
It runs as a non-root user with a read-only root filesystem and your data on a named volume.
docker compose defaults to open mode (anyone reachable can read and push) — fine for a trusted
LAN; set MCAHUB_TOKEN or the MCAHUB_OAUTH_* vars before exposing it publicly (see the compose file).
The image bundles the Rust mcagit binary (the hub spawns it for transport + rendering); see the
Dockerfile for how it's built/copied in.
Needs the .NET 10 SDK and the Rust mcagit binary — the hub drives it out-of-process (no more
.NET-core submodule). Build mcagit (cargo build --release in the mcagit repo) and put it on PATH, or
point MCAGIT_BIN at it:
git clone https://github.com/<you>/mcahub
export MCAGIT_BIN=/path/to/mcagit/target/release/mcagit # or just have `mcagit` on PATH
dotnet run --project src/McaHub # serves http://localhost:5080A self-contained binary for your OS (no SDK needed) is attached to each GitHub Release.
Then point a client at it (the same mcagit binary, as a client):
mcagit init MyWorld.mcagit --worktree path/to/world
mcagit -C MyWorld.mcagit commit -m "first backup"
mcagit -C MyWorld.mcagit push http://localhost:5080/r/myworld main # auto-creates "myworld"Open http://localhost:5080 to browse it.
For hands-off backups, run the sidecar (src/Sidecar) next to your server — it watches a world
directory and auto-pushes a backup on a schedule and whenever the world changes (debounced), committing
only when something actually changed, plus one final backup on shutdown:
MCAGIT_BIN=/path/to/mcagit \
MCASIDE_WORLD=/srv/minecraft/world \
MCASIDE_REMOTE=http://localhost:5080/r/myworld \
MCASIDE_TOKEN=mcahub_… \
dotnet run --project src/Sidecar # or the published `mcahub-sidecar` binaryThe sidecar also drives the Rust mcagit binary (init/commit/push), so it needs mcagit on
PATH or MCAGIT_BIN. This is the no-Java, any-server path; a drop-in Paper/Spigot/Fabric plugin
(which can also save-off/save-all around the snapshot) is tracked as a separate Java deliverable.
| Setting | Env var | Default | Purpose |
|---|---|---|---|
| mcagit binary | MCAGIT_BIN |
mcagit (on PATH) |
The Rust mcagit binary the hub spawns for transport (serve) + rendering/diff/query. |
| Data dir | MCAHUB_DATA |
data/repos |
Where hosted <name> repos live (mcagit blake3/zstd format). |
| World cache | MCAHUB_CACHE |
data/cache |
Materialized worlds for the explorer (one checkout per immutable backup). |
| Map cache | MCAHUB_MAPS |
data/maps |
Rendered map PNGs (one per immutable commit). |
| Account DB | MCAHUB_DB |
data/hub.json |
Users, hashed tokens, repo ownership/visibility. |
| Audit log | MCAHUB_AUDIT |
data/audit.jsonl |
Append-only trail of role/visibility/ownership/ref/token changes; owners see a per-world history at /r/<name>/audit. |
| Push token | MCAHUB_TOKEN |
(none) | A shared/master token. In open mode it gates writes; in accounts mode it's an admin bypass. |
| Push token (hashed) | MCAHUB_TOKEN_SHA256 |
(none) | SHA-256 hex of the master token(s) — keeps the plaintext out of the env. Accepts a comma/space list so you can rotate without downtime: add the new hash, switch clients, then drop the old one. |
| Bind URL | ASPNETCORE_URLS |
http://localhost:5080 |
Put it behind a reverse proxy for TLS. |
The hub parses and stores attacker-supplied world data, so the heavy paths are bounded. Defaults are generous for normal use; lower them on small/shared hosts.
| Env var | Default | Purpose |
|---|---|---|
MCAHUB_MAX_PUSH_BYTES |
268435456 (256 MiB) |
Max push body buffered; larger is rejected with 413 (also raises the Kestrel limit to match). |
MCAHUB_CACHE_MAX_GB |
10 |
Size ceiling for the materialized world cache; least-recently-used worlds are evicted past it. |
MCAHUB_MAX_WORLDS_PER_REPO |
10 |
Keep at most this many materialized worlds per repo. |
MCAHUB_MAP_CACHE_MAX_GB |
2 |
Size ceiling for the rendered map PNG cache (LRU eviction). |
MCAHUB_MAX_MAPS_PER_REPO |
100 |
Keep at most this many map PNGs per repo. |
MCAHUB_MAX_MANIFEST_ENTRIES |
100000 |
Refuse to materialize a world whose manifest has more file/dir entries (inode-exhaustion guard). |
MCAHUB_MAX_RENDER_CONCURRENCY |
3 |
Max map renders running at once. |
MCAHUB_MAX_RENDER_CHUNKS |
10000 |
Max chunks decoded per render; bigger worlds truncate. |
MCAHUB_RENDER_TIMEOUT_SECONDS |
30 |
Hard server-side deadline for a single map render. |
MCAHUB_RATELIMIT_AUTH |
20 |
Auth / token requests per IP per minute. |
MCAHUB_RATELIMIT_WRITE |
60 |
Push (transport write) requests per IP per minute. |
MCAHUB_RATELIMIT_RENDER |
30 |
Cold map renders per IP per minute. |
MCAHUB_RATELIMIT_READ |
300 |
Read / page requests per IP per minute. |
MCAHUB_AUTH_MAX_FAILURES |
5 |
Bad Bearer tokens from one IP before a temporary lockout. |
MCAHUB_AUTH_LOCKOUT_SECONDS |
30 |
Base lockout after the failure threshold (doubles with continued failures). |
A full cache evicts oldest-first and, if a single entry exceeds the ceiling, refuses it with a clear
error rather than silently filling the disk. Rate limits are per client IP and return 429 with
Retry-After; behind a reverse proxy, set MCAHUB_BEHIND_PROXY=1 so the real client IP is used (else
every client shares one bucket).
Operating. GET /health returns 200 {"status":"ok",…} for proxy/orchestrator liveness probes — it's
unauthenticated and rate-limit-exempt, so don't block it at the proxy. If the disk fills, a write to
the account database fails with 507 Insufficient Storage (the in-memory state isn't advanced, so
nothing is silently lost) instead of a 500. hub.json carries a schema version: a file written by a
newer hub makes this one refuse to start with an actionable message rather than misread and
overwrite it — back it up and upgrade.
On SIGTERM (a deploy, docker stop, systemd) the hub drains — it stops accepting new connections
and lets in-flight requests finish (up to the render deadline) instead of guillotining a render. Cold map
renders (which also materialize the backup's world) run as background jobs on a hosted worker pool, not
on the request thread: a client that disconnects or times out no longer aborts the render — it finishes and
fills the immutable, commit-keyed cache for the next viewer. Each pending job has a durable marker that
is re-enqueued on startup, so a render interrupted by a crash or deploy resumes rather than waiting to be
re-triggered (and a resumed job that already completed simply hits the warm cache).
Two instances may share one data directory for zero-downtime rolling deploys: every account-store write
takes a cross-process advisory lock (hub.json.lock) and reloads hub.json before mutating, then
publishes atomically — so a concurrent writer can never tear or clobber the store, and neither instance
overwrites the other's committed change. Reads reload when the file changed underneath them, so a revoked
token or changed grant on one instance is seen by the other on its next read. A sustained two-writer workload
still wants vertical scaling (or a future real DB).
The hub runs in one of three modes, chosen by what you configure:
- Open (default — nothing set): anonymous, every world public, open push. Great for a trusted LAN.
As a safety net, the hub refuses to start in open mode on a non-loopback bind unless you set
MCAHUB_I_KNOW_OPEN_MODE_IS_PUBLIC=1— so you can't accidentally expose anonymous write to the internet. (MCAHUB_DEV_LOGINis refused off-loopback with no override at all.) - Token (
MCAHUB_TOKENset): reads anonymous, writes need--token <that token>. - Accounts (OAuth configured): real users sign in via OAuth; each gets personal access tokens for the
CLI; worlds can be private. The CLI can't run a browser redirect, so
mcagit push/cloneagainst a private world uses a personal access token (mint one at/account) — exactly how GitHub handlesgit push. Tokens carry a scope (read= clone/fetch,write= + push), an optional expiry, and can be regenerated (rotated) or wiped with "sign out everywhere" (revokes all tokens and invalidates every web session). An owner shares a private world by adding collaborators on the world's page, or by granting a team (a named group managed at/teams) — every member inherits the team's role. Roles form a ladder: read (browse/clone) → write (+ push) → maintain (+ change visibility) → admin (+ manage collaborators & team grants), with the owner above all. Effective access is the strongest of owner, direct collaborator, and any team grant. Every state-changing form is CSRF-protected with an antiforgery token (on top of theSameSite=Laxsession cookie).
You can enable several providers at once — each is on iff its own client id+secret are set, and the
sign-in page shows a button per enabled provider. Identities are namespaced (github:…, microsoft:…,
minecraft:<uuid>, discord:…) so they never collide. Each first-class provider's callback is
https://<host>/auth/callback/<name> (the legacy generic one keeps /auth/callback).
| Accounts env var | Default | Purpose |
|---|---|---|
MCAHUB_OAUTH_GITHUB_CLIENT_ID / _SECRET |
— | GitHub sign-in. |
MCAHUB_OAUTH_MICROSOFT_CLIENT_ID / _SECRET |
— | Microsoft sign-in (work/school/personal). |
MCAHUB_OAUTH_MICROSOFT_TENANT |
common |
common (any) or consumers (personal only). |
MCAHUB_OAUTH_MINECRAFT_CLIENT_ID / _SECRET |
— | Minecraft (Java) sign-in — yields the verified Minecraft UUID + username. The Azure app must be approved for the Minecraft API (apply at aka.ms/mce-reviewappid) and use the consumers tenant, or api.minecraftservices.com returns 403. |
MCAHUB_OAUTH_DISCORD_CLIENT_ID / _SECRET |
— | Discord sign-in. |
MCAHUB_OAUTH_CLIENT_ID / _SECRET (+ _PROVIDER / _AUTH_URL / _TOKEN_URL / _USER_URL / _SCOPE) |
— | Generic OAuth2 escape hatch (GitLab, Gitea, any OIDC). Kept for back-compat; callback stays /auth/callback. |
MCAHUB_DEV_LOGIN |
(off) | ⚠ Insecure local login at /auth/dev for evaluating accounts without an OAuth app. Setting this turns on accounts mode on its own — anonymous pushes then fail and the CLI needs a personal access token. Never on a public host. |
MCAHUB_ADOPT_UNOWNED |
(off) | Let the first authenticated push to a pre-existing unowned world claim it. Off by default so a signed-up user can't take over a legacy world; turn it on only during a supervised migration. |
MCAHUB_DEFAULT_PRIVATE |
1 (on) |
New worlds are private until you publish them, so a first push isn't world-readable by surprise. Set 0 for public-by-default on a trusted LAN. (Separately, the world explorer only shows player coordinates/health and sign text to a world's collaborators, so a public world doesn't doxx its players.) |
MCAHUB_REPORT_EMAIL |
— | Abuse-report address. When set, non-owners see a "Report this world" link on each world's page. The operator can take a world down with the master token: curl -X POST -H "Authorization: Bearer <MCAHUB_TOKEN>" https://<host>/admin/repos/<name>/remove. (A user can also be suspended — a non-destructive lockout from read/write.) |
MCAHUB_MIN_AGE_GATE |
(off) | Require a 13+/parental-consent confirmation on first sign-in before any page works (logged). Off by default for school/LAN self-hosts; turn it on for a public launch (Minecraft skews young). |
MCAHUB_MAX_WORLDS_PER_USER |
0 (∞) |
Fair-use cap on how many worlds one account may own (a new push past it gets 403). 0 = unlimited. Distinct from the per-IP/size DoS limits above — this is governance, to stop one user flooding the home page. |
MCAHUB_DISCORD_WEBHOOK |
— | (Deferred during the Rust-engine port — the on-push grief-alert embed is being reimplemented over mcagit's grief output; the var is currently inert.) |
A public world's pages carry OpenGraph/Twitter meta so a pasted link unfurls into its map, and /r/<name>/embed is a chrome-less, iframe-embeddable map for forums/wikis.
Every page links the built-in Acceptable Use Policy (/aup) — the agreement that gives the operator a basis for takedown.
A user can delete their account (and all worlds they own) from /account, and an owner can delete a world from its page — GDPR/CCPA erasure, both with a typed confirmation.
When accounts are on, a push to a genuinely new name auto-creates and claims it. A world that already
exists on disk with no owner (e.g. pushed before accounts were enabled) is not claimable by a
regular user — an admin must adopt it, or you can open a supervised migration window with
MCAHUB_ADOPT_UNOWNED=1. The hub logs a warning at startup listing any unowned worlds.
-
Create an OAuth App — https://github.com/settings/developers → New OAuth App:
- Homepage URL:
http://localhost:5080 - Authorization callback URL:
http://localhost:5080/auth/callback
(The callback host must match how you reach the hub — the redirect URI is derived from the request, so use your real
https://hostin production.) - Homepage URL:
-
Register, then copy the Client ID and Generate a new client secret.
-
Drop them into
.envat the hub root (cp .env.example .env, then fill in):MCAHUB_OAUTH_CLIENT_ID=<your client id> MCAHUB_OAUTH_CLIENT_SECRET=<your client secret>
.envis gitignored and auto-loaded at startup (your shell environment still wins over it). -
Run from the hub root:
dotnet run --project src/McaHub. The log should readauth: accounts (github OAuth). Visit http://localhost:5080, click Sign in, then mint a token at/accountformcagit push.
Behind a TLS-terminating reverse proxy, register the https://…/auth/callback URL and set
MCAHUB_BEHIND_PROXY=1 so the hub honors X-Forwarded-Proto/Host/For and builds an https redirect URI.
Those headers are trusted only from the proxy — MCAHUB_TRUSTED_PROXY (IP or CIDR, default loopback);
keep the app port itself unreachable from clients, or a spoofed X-Forwarded-Host could hijack the OAuth
redirect / clone URLs.
Running it for real — what to back up, how to upgrade, and how to keep logs from filling the disk.
| State | Path | Durable? |
|---|---|---|
| Hosted worlds | data/repos (MCAHUB_DATA) |
Yes — the only copy of pushed history |
| Accounts DB | data/hub.json (MCAHUB_DB) |
Yes — users, hashed tokens, ownership, grants |
| Audit log | data/audit.jsonl (MCAHUB_AUDIT) |
Yes (compliance) |
| World cache | data/cache (MCAHUB_CACHE) |
No — re-materialized on demand |
| Map cache | data/maps (MCAHUB_MAPS) |
No — re-rendered on demand |
Back up data/repos + data/hub.json (+ data/audit.jsonl); skip the caches — they rebuild
themselves. Both durable stores are safe to copy while the hub runs: hub.json is written with an
atomic temp-then-rename (a snapshot is never torn), and data/repos holds append-only, atomically-published
packs, so an rsync/filesystem snapshot is crash-consistent. For a fully consistent backup (no
in-flight push mid-pack), stop the hub first. A leftover hub.json.tmp after a crash is a harmless
orphan — the live hub.json is intact; delete the .tmp.
Restore: stop the hub, drop the backed-up data/repos + data/hub.json back in place, start it; the
caches refill on first view. Migrate to a new machine: copy data/repos, data/hub.json, and your
.env, then update the OAuth app's callback URL to the new host.
The hub and the engine version independently now — the engine is the mcagit binary, not a submodule:
# 1. stop the hub 2. back up hub.json
git pull && dotnet build src/McaHub -c Release # the hub (C#)
# update the mcagit binary too (cargo build --release in the mcagit repo); keep MCAGIT_BIN on the new one
# 3. start the hubKeep the mcagit binary roughly in step with the hub: the hub parses mcagit's --json output, so a
breaking CLI/JSON change on the mcagit side needs a matching hub update — pin a known-good pair. An
incompatible hub.json from a newer build refuses to start with an actionable message (see the
schema-version guard) rather than corrupting your data.
The hub logs to stdout. On a busy host, bound it: under systemd/journald set journalctl's
SystemMaxUse=, or logrotate a redirected log file. To silence per-request access logs in production,
set Logging__LogLevel__Microsoft.AspNetCore=Warning (env var). GET /health is unauthenticated and
rate-limit-exempt for liveness probes — don't block it at the proxy.
The C# layer is the web/auth/accounts shell; all world logic is the Rust mcagit engine, driven
out-of-process via RustEngine (src/McaHub/RustEngine.cs — shells the binary + parses --json/PNG):
RepoStore— hosts bare mcagit repos at<data>/<name>(blake3/zstd format); repo names are validated so they can't escape the data dir. Listing/branches/commit-times come frommcagit log+cat-file.Transport— keeps mcahub's auth/accounts/throttle/audit gate, then reverse-proxies the transport protocol (/r/{repo}/{info-refs,have,objects,refs/heads}) to a co-locatedmcagit servesidecar (started byProgramon a loopback port). The sidecar speaks mcagit's object/ref protocol (fast-forward guard + blake3 hash-verify on store); a first push auto-creates + claims the world.Pages— server-rendered HTML (no SPA): the repo list, a repo's timeline, a backup view, a compare-any-two-backups view, a world explorer, and the time-machine scrubber (/r/{repo}/timeline). The diff + grief summary come frommcagit diff --json+mcagit where-changed --json(a backup's materialized world vs its parent's); the explorer frommcagit players/find --json. Backup data is embedded asSystem.Text.Jsonand captions set viatextContent, so commit messages can't inject script.WorldCache— materializes a backup tocache/<repo>/<commit>once viamcagit checkout(commits are immutable) so the dir-based queries/renders read a real world without re-checking-out per page view.MapRenderer⇒mcagit render+MapCache— the top-down surface map per backup is produced by the Rust renderer and cached per immutable commit; a cold render shows a "Generating map…" spinner and reveals the image once it loads (the scrubber re-shows it each step). Renders run as background jobs off the request thread.Auth+HubDb— identity and the tiny JSON account store (unchanged by the engine swap).Authwires the framework's cookie + OAuth handlers (no third-party package), splits web identity (cookie) from CLI identity (Bearer PAT), and holds the sharedCanRead/CanWriterules used by both the web pages and the transport.HubDbkeeps users, hashed tokens (the plaintext is shown once and never stored), per-repo owner/visibility, collaborator grants, and teams + team grants.HubDb.RoleOfresolves a user's effective role on a repo (owner > admin > maintain > write > read > none) by folding the owner, any direct collaborator grant, and any team grant the user inherits —CanRead/CanWrite, theCanManageSettings/CanManagePeoplecapability checks, and the UI all go through it, so they can't drift. State-changing POSTs are validated with ASP.NET antiforgery tokens (issued via a hidden field, validated manually so the Bearer-authenticated transport POSTs are never touched).
Shipped: hosting + browse + per-backup diff + grief forensics, compare any two backups, a
world explorer (players + find an entity / block entity / sign, backed by a materialize-once world
cache), rendered maps + a time-machine scrubber, one-click restore, accounts (OAuth
sign-in, per-user tokens, public/private worlds, collaborators, teams), multi-provider sign-in (Microsoft,
Minecraft, Discord), COPPA age gate, AUP page, abuse-report link + operator takedown, user suspension,
account/world deletion (GDPR/CCPA erasure), per-user world quota, and an audit log (/r/<name>/audit).
The hub is now fully ported to the Rust mcagit engine — no in-process .NET core, no submodule.
Natural next steps:
- Re-add the on-push Discord grief alert over mcagit's grief output (deferred during the engine port).
- Surface more of mcagit's world tooling in the UI — a coordinate
inspectpage (block + properties + biome + block-entity), region heatmaps, per-player inventory views. - Map thumbnails on the backup timeline, and a focusable region/coordinate jump in the map.
GPL-3.0 (matching mcagit). See LICENSE.