Self-hosted service
itzg/mc-router avatar
itzg/mc-router

mc-router: many Minecraft servers behind one public port

Routes Minecraft client connections to backend servers based upon the requested server address

969 stars79 forksGoMIT

At a glance

What is it?
mc-router is a Go proxy that sits in front of one public Minecraft port and picks a backend based on the server address the client asked for, with Docker and Kubernetes discovery, scaling to zero, connection rate limits and Prometheus metrics. It is well suited to a home network running several servers, and less suited to a large fleet with real load balancing needs.
Who is it for?
Adopt mc-router when you run several Minecraft servers at home and want them reachable through one public address and port, especially if the servers run as containers and should scale to zero when nobody is playing. It is the wrong tool for a fleet that needs weighted load balancing across identical backends, for Bedrock edition, which this project does not cover, and for a deployment where a single connection rate limit shared by all clients is unacceptable.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 1 day ago.
What is it written in?
Mainly Go, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.

Editorial analysis

The home network problem: one port, many servers

Running more than one Minecraft server at home runs into a network problem before it runs into a software problem. Each server wants a port, and only some of them are reachable from outside, because a home connection has one public IP address and the router forwards one set of ports. The usual workarounds are all unpleasant: a different port for every server and a port forward for each, a separate IP address, or a generic TCP proxy that cannot tell which server a client wanted.

mc-router addresses exactly that last problem. It routes Minecraft Java Edition client connections to backend servers based upon the requested server address, and the README describes the payoff plainly: any number of Minecraft servers can be multiplexed onto a single public IP address and standard Minecraft port. A client types a hostname, the router looks at which address was requested, and forwards the connection to the matching backend. The project works particularly well with itzg/minecraft-server, though it is not tied to it and can be used with any Minecraft server.

The audience is the home and self-hosted operator, and the second audience is the container user, since a Docker image is published and stand-alone binaries are also available for Linux, Mac and Windows for anyone who does not want containers at all. The topics list gives the same picture, with docker-image, minecraft, minecraft-server and hacktoberfest.

The code is Go, and the module is github.com/itzg/mc-router with a go 1.27.2 directive. The dependency list is the interesting part of the design, because it is essentially the feature list expressed as libraries: the Docker client for container discovery, the Kubernetes client-go packages for cluster discovery, go-proxyproto for the PROXY protocol, the Prometheus client for metrics, the InfluxDB 1 client for the alternative metrics backend, juju/ratelimit for the connection rate limit, ngrok for the tunnel integration, and gorilla/mux and go-kit for the HTTP surface. There is a mcproto package in the tree, which is where the Minecraft protocol handling lives.

The mapping table and how a request picks a backend

The core configuration is a mapping from an external hostname to a backend address. The flag documentation describes it as comma or newline delimited or repeated mappings of externalHostname=host:port, and it is available as the MAPPING environment variable. Alongside it there is a default backend for the case where no mapping matches:

text
  -mapping value
    Comma or newline delimited or repeated mappings of externalHostname=host:port (env MAPPING)
  -default string
    host:port of a default Minecraft server to use when mapping not found (env DEFAULT)

The default is worth thinking about, because it changes the behaviour of the router in a way that is easy to overlook. With a default set, a client connecting with an address that matches no mapping is forwarded somewhere, which is convenient when you want one hostname to serve a fallback server. With no default set, that connection is not proxied at all, and the router instead answers it in a way that reveals nothing about the backends.

That is the second half of the design, and the README states it as a security property rather than a routing detail. mc-router minimizes unwanted intrusions by disallowing Minecraft port scanners that are not specifying a mapped server hostname. The Minecraft handshake carries the address the client thinks it is connecting to, and a scanner that only has an IP address and no hostname will not match a mapping, so it is rejected rather than being handed to a backend. If you set a default, you give that property up for unmatched clients, which is the trade-off.

The client address can also be carried explicitly. The -dynamic-proxy-protocol flag accepts connections with or without a PROXY protocol header and forwards the header to the backend when one is present, and the flag documentation notes that it cannot be combined with -receive-proxy-protocol or -use-proxy-protocol. In other words, the router can either infer the client address from the PROXY protocol or accept it, but it will not do both at once, which keeps the source of truth for the client IP unambiguous.

Discovery across Docker, Swarm and Kubernetes

The feature that makes the router more than a static forwarding table is dynamic auto-discovery of backend servers in Kubernetes and Docker, which the README describes as allowing for zero-config mapping of your Minecraft server hostnames to backend servers behind mc-router.

The discovery backends are chosen by flag, and each one has its own environment variable:

text
  -in-docker
    Use Docker service discovery (env IN_DOCKER)
  -in-docker-swarm
    Use Docker Swarm service discovery (env IN_DOCKER_SWARM)
  -in-kube-cluster
    Use in-cluster Kubernetes config (env IN_KUBE_CLUSTER)
  -kube-namespace string
    The namespace to watch or blank for all, which is the default (env KUBE_NAMESPACE)

The in-cluster option uses the service account of the pod, which is convenient and also the thing to scrutinise: watching a namespace means listing and watching resources in it, and watching all namespaces means doing that across the whole cluster. The -kube-config flag points at a configuration file instead, for the case where the router runs outside the cluster.

Docker discovery uses the Docker socket, whose path is set with -docker-socket, and the socket grants a lot: the Docker API is a control interface for the host, and an unrestricted socket mount is effectively root on the host. The examples directory includes docker-autoscale-socket-proxy, and there is an example folder for docker-discovery and one for docker-discovery-ipv6, which suggests the socket proxy pattern is the intended way to narrow the grant.

One flag shows the project cleaning up after itself. The -docker-refresh-interval flag is marked deprecated and ignored, with the note that Docker discovery is now event-driven. A discovery mechanism that polls on a timer has a window in which a newly started container is invisible, and an event-driven one does not, which is a better behaviour for a router that has to react to a server that is scaling up.

The -docker-api-version flag exists for the cases where auto-negotiation against a daemon picks the wrong version, and -docker-timeout bounds the calls made to the Docker API.

Scaling to zero and the asleep MOTD

The auto-scaling feature is the most interesting thing in the router, and it comes in two shapes. For Kubernetes it decreases StatefulSet replicas from 1 to 0 and back. For Docker it starts or unpauses a container when accessed and gracefully stops it when there are no connections. The switches that turn it on are explicit:

text
  -auto-scale-up
    Scale from zero on access. For Kubernetes, increases StatefulSet replicas from 0 to 1. For Docker, starts or unpauses the container when accessed (env AUTO_SCALE_UP)
  -auto-scale-down
    Scale to zero after idle. For Kubernetes, decreases StatefulSet replicas from 1 to 0. For Docker, gracefully stops the container when there are no connections (env AUTO_SCALE_DOWN)
  -auto-scale-down-after duration
    Server scale down delay after there are no connections (env AUTO_SCALE_DOWN_AFTER) (default 10m0s)

A player who joins a scaled-to-zero server has to wait for it to wake, and the router has to say something while that happens. Two MOTD settings cover the states. The loading MOTD is displayed while an auto-scaled Docker server is waking up, and if it is empty the asleep status is served instead. The asleep MOTD is what a player sees when a scaled-down server is accessed, and if it is empty no status is served at all. Both are per-server settings.

The timing of the wake-up is bounded by -backend-dial-timeout, which defaults to two seconds and is described as the timeout for establishing the TCP connection to a backend. Its purpose is specific: it bounds how long a dial to a scaled-to-zero server's Service with no endpoints waits before the auto-scale asleep MOTD or the scale-up fallback is served. An operator with fast in-cluster backends can lower it so the fallback fires sooner. That flag is the difference between a player staring at a connection and a player being told the server is starting.

The scale-down timer is reset by player logins by default, and the -auto-scale-status-resets-down-timer flag changes that so that status requests proxied to the server also reset it. That distinction matters if something polls the server for its status: with the flag on, a health check can keep a scaled-down server alive indefinitely.

There is a second scaling path for backends that are not Kubernetes or Docker. The -auto-scale-webhook-url flag points at an endpoint that the router POSTs to when a statically configured backend is accessed and after it goes idle, with -auto-scale-webhook-headers for key=value pairs such as authentication tokens, a per-request timeout defaulting to thirty seconds, and a wake timeout of one minute that bounds how long the router waits for the backend to become reachable after the webhook fires.

The edge guards: rate limits, allow lists and notifications

A router sitting on a public port is a target, and mc-router carries a set of controls aimed at that. The first is a connection rate limit, and its default is aggressive:

text
  -connection-rate-limit int
    Max number of connections to allow per second (env CONNECTION_RATE_LIMIT) (default 1)

One connection per second is a sensible default against a flood and a poor default for a busy server, where a party of players joining at once will be throttled. Raising it is a configuration change, but it is worth understanding what the number means before a launch night.

The second control is address based, and it is an allow list that wins over a deny list:

text
  -clients-to-allow value
    Zero or more client IP addresses or CIDRs to allow. Takes precedence over deny. (env CLIENTS_TO_ALLOW)
  -clients-to-deny value
    Zero or more client IP addresses or CIDRs to deny. Ignored if any configured to allow (env CLIENTS_TO_DENY)

The precedence rule has a consequence that is easy to misread. The deny list is ignored entirely if any address appears on the allow list, so a configuration with one allowed office IP and a deny list silently stops enforcing the deny list. The documentation states it plainly, but a reader skimming will take it the other way.

The third control is observability and notification. A webhook integration notifies other systems when a player connects to and disconnects from a server, which is what a Discord bot or a moderation system would consume. The metrics side exposes counters through a selected backend, and the choice is explicit:

text
  -metrics-backend string
    Backend to use for metrics exposure/publishing: discard,expvar,influxdb,prometheus (env METRICS_BACKEND) (default "discard")

Discard is the default, so metrics are off unless you ask for them, and when enabled the metrics include player login information. The API surface for this and for the rest of the management functions is bound separately with -api-binding, so the metrics endpoint does not have to share the port that the game traffic uses.

The last edge feature is the built-in ngrok integration, where mc-router acts as an agent, which is a way to expose a server that is not directly reachable without opening a port on the router.

Deployment shapes and the files that come with them

mc-router can be run in several ways, and the repository is organised to support them.

The container image is the primary distribution. The Dockerfile in the repository builds a static binary with CGO_ENABLED=0 in a golang:1.27.2 builder stage, then copies the binary into a chainguard static image with the binary as the entry point. The result is an image with no shell and no package manager, which is the right shape for something that sits in front of game traffic. A second Dockerfile, Dockerfile.release, exists for the release build, and the goreleaser configuration publishes the stand-alone binaries for Linux, Mac and Windows that the README mentions as the alternative to containers.

For Kubernetes users, the repository has a kustomize directory, and for the development loop there is a skaffold.yaml and a .devcontainer directory. The examples directory carries the configurations people actually copy: docker-autoscale, docker-autoscale-multi, docker-autoscale-socket-proxy, docker-discovery, docker-discovery-ipv6 and swarm. The socket proxy example is the one to look at first, because it is the pattern that avoids handing the router the whole Docker socket.

The source layout is a normal Go project: cmd for the entry point, server for the proxy and API logic, mcproto for the Minecraft protocol, library.go at the root, and test and docs directories. There is a DEVELOPMENT.md for working on it, an AGENTS.md at the root, and a testing.http file with sample requests for the API, which is a convenient way to exercise the management endpoints from an editor.

The dependency versions are pinned aggressively, with the Docker client at v28.5.2 and the Kubernetes libraries at v0.37.1, and a renovate.json at the root is there to keep them current. The go.mod also contains a deliberate exclude for a specific version of google.golang.org/genproto, with a comment explaining that go-kit pulls in an old package that is ambiguous with the googleapis ones. That is a small artefact of the dependency set and a useful hint about how careful the maintenance is.

Licence, release pace and the limits of the design

The licence is MIT, with the terms in a LICENSE.txt file at the repository root, so the router can be used, modified and redistributed in commercial settings. The repository also ships a SECURITY.md for reporting problems and a Discord link for discussion. This describes the published terms rather than legal advice.

The release record shows a project in active development. Version 1.47.1 was published on 2026-09-18, v1.47.2 on 2026-10-04 and v1.48.0 on 2026-10-14, and the last push was on 2026-10-18. Releases are frequent enough that pinning a version is worth doing deliberately rather than tracking the latest tag, and the changelog for each release is where the behaviour changes are described, since several of the flags in the list have defaults that were tuned over time.

Now the limits. The router decides a backend from the requested address, and the address has to be one it knows. That model suits a set of named servers and does not suit a fleet of interchangeable backends that want weighted distribution, health checking and retries, which is what a general load balancer does. The examples directory shows the multi-server case, but there is no documentation of traffic weighting between them.

The second limit is the protocol. The project is for Minecraft Java Edition, and the Minecraft protocol handling in mcproto is what the router inspects to find the requested address. That inspection is a parsing step, and it is the part most likely to be affected by a protocol change in the game, which is a maintenance consideration the project does not discuss in the visible documentation.

The third is the privilege question. Both discovery modes and the Docker socket path require the router to hold a powerful credential, and a router that can start and stop containers can also be used to start and stop them in ways you did not intend. The socket proxy example exists because of that, and choosing between it and the raw socket is a decision about blast radius rather than about convenience.

Editorial conclusion

Adopt mc-router when you run several Minecraft servers at home and want them reachable through one public address and port, especially if the servers run as containers and should scale to zero when nobody is playing. It is the wrong tool for a fleet that needs weighted load balancing across identical backends, for Bedrock edition, which this project does not cover, and for a deployment where a single connection rate limit shared by all clients is unacceptable. Before deploying, set the -mapping entries explicitly rather than relying on discovery, confirm the -connection-rate-limit default of one connection per second suits your player count, and decide whether the Docker socket or the Kubernetes service account is the smaller privilege to grant.

Frequently asked questions

What does mc-router do and who needs it?

It routes Minecraft Java Edition client connections to backend servers based on the requested server address, so any number of servers can share a single public IP address and standard Minecraft port. It is aimed at home and self-hosted deployments, and it works with itzg/minecraft-server or any other Minecraft server.

How do I map a hostname to a Minecraft server in mc-router?

Use the -mapping flag with externalHostname=host:port entries, comma or newline delimited or repeated, or set the MAPPING environment variable. The -default flag names a backend to use when no mapping matches.

Does mc-router support Kubernetes and Docker discovery?

Yes. The -in-kube-cluster flag uses the in-cluster Kubernetes configuration, -kube-namespace limits the watch, and -in-docker or -in-docker-swarm enable Docker and Swarm service discovery. Docker discovery is event-driven now, and the old -docker-refresh-interval flag is deprecated and ignored.

How does auto-scaling to zero work with mc-router?

With -auto-scale-up the router increases StatefulSet replicas from 0 to 1 on Kubernetes, or starts and unpauses the container on Docker, and -auto-scale-down reverses it after the -auto-scale-down-after delay. A MOTD can be shown while a server is waking or asleep, and the -backend-dial-timeout bounds the wait before that status is served.

What security controls does mc-router provide for a public port?

It rejects connections that do not name a mapped server hostname, rate limits incoming connections with a default of one per second, and can allow or deny client IP addresses and CIDRs. Note that the deny list is ignored whenever any address is on the allow list.

Official sources

  1. Issues
  2. itzg/mc-router on GitHub
  3. License: MIT
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/itzg-mc-router.svg)](https://hysenlabs.com/projects/itzg-mc-router)

Was this useful?