Home Blog Building a TCP proxy for Minecraft servers with Caddy, Tailscale, and Docker

Caddy logo

Building a TCP proxy for Minecraft servers with Caddy, Tailscale, and Docker

Do you want to self-host a Minecraft server that needs a RAW tcp connection, but you don't want to expose your local network to the internet? You can use Caddy, Tailscale, and Docker for easy deployment.

What you'll need:

  • Domain name for easy access
  • A server to run your proxy on
  • A server/computer to run your Minecraft server
  • A Tailscale account in which you've added the server/computer that runs your Minecraft server and your proxy server to the tailnet
  • Docker installed on the proxy server

For context, my setup has a few extra limitations: my server is ipv6 only, so ipv4 ip addresses won't work, and instead of creating an A record in your DNS settings, I had to create an AAAA record. An additional limitation for me was that the server had an ARM infrastructure, rather than AMD64. Luckily, this limitiation is easy to overcome when using docker. I'll mention the additional steps in the guide.

What I won't explain

In this guide, there are a few things I consider "out-of-scope", such as: Installing tailscale on your servers, DNS settings, setting up a domain, setting up servers, and installing Docker. I consider these out-of-scope, because there are an infinite different ways to accomplish these things, different registrars, different cloud providers, different server/desktop operating systems, etc.

For some additional context, these are the settings/services I used:

  • DNS is managed in Cloudflare: proxy settings is OFF
  • Proxy server is from Hetzner, ARM64 server, ipv6 only, running Ubuntu server 24
  • Game server is a mini PC in my home, running Ubuntu desktop 24, AMD64 architecture
  • Both servers are added to the same Tailnet and can only communicate on port 25565 (default minecraft port)

Create a docker image

Caddy, by default, supports being used as an HTTP reverse proxy. It doesn't, out-of-the-box, support proxying raw TCP. Luckily, there are ways for us to give it this ability by using the layer4 plugin. To be able to use this plugin, we'll have to create a custom Docker image. You can create one yourself using this Dockerfile, or simply use mine: roelofjanelsinga/caddy-tcp-proxy (it's free and publicly available).

The contents of my Dockerfile are:

FROM caddy:2.10-builder AS caddy-builder

RUN xcaddy build \
    --with github.com/mholt/caddy-l4

FROM caddy:2.10-alpine

COPY --from=caddy-builder /usr/bin/caddy /usr/bin/caddy

Since my server is using the ARM64 architecture, and my current development system is using an AMD64 architecture, I need to specifically target building for ARM. Warning, this takes quite a while!

This is how you can build the Docker image:

docker buildx build --platform linux/arm64,linux/amd64 -t your-image-name .

If you don't need to target the ARM architecture, you can omit the platform parameter and run:

docker buildx build -t your-image-name .

If you're using my provided image, you can use it both on ARM and AMD64, since I targeted both architectures.

Create a Caddy configuration file

Caddy is known to be very easy to configure, so it shouldn't be much of a surprise that this configuration is also quite simple.

These are the contents of my caddy.json file:

{
  "logging": {
    "logs": {
      "": {
        "level": "DEBUG",
        "writer": {
          "output": "stdout"
        },
        "encoder": {
          "format": "json"
        }
      }
    }
  },
  "apps": {
    "layer4": {
      "servers": {
        "mc-server": {
          "listen": [":25565"],
          "routes": [
            {
              "handle": [
                {
                  "handler": "proxy",
                  "upstreams": [
                    {"dial": ["your-ip-address:25565"]}
                  ]
                }
              ]
            }
          ]
        }
      }
    }
  }
}

As you can see, I added some logging, this is completely optional, but I prefer lots of logging, especially when setting up new systems.

In the apps section, you'll notice the layer4 key. This tells us that we want to communicate in raw TCP, rather than HTTP. In this section, you'll specify your servers. I called mine mc-server, but this can be whatever you want it to be.

In the listen section, I specify which port Caddy should listen at. In this case, I want to listen to port 25565, as this is the default port Minecraft looks for a server. You can customize this port, but then you'll need to search for a server at your-domain.com:your-chosen-port, rather than just your-domain.com.

In the routes section, we're adding a handler, in this case a simple proxy. In the upstreams, you'll want to add your tailscale IP. You can get your tailscale IP in the admin dashboard, or from the command line: sudo tailscale ip your-minecraft-server-name. This command will return 2 ip adresses. The first one is your ipv4 IP address and the second is your ipv6 address.

If you're also using a server that only has IPV6, grab the second IP address and paste it in your upstream like so: [your:ipv6:ip::address:here]:25565 (notice the brackets). If you're server does support ipv4, use the first tailscale IP address and paste it like the provide sample configuration above.

Setting up docker compose

We're almost there! We've got Caddy and Docker configured, so now we'll need a docker-compose.yml to tie it all together. Here's mine:

services:
    caddy:
        image: roelofjanelsinga/caddy-tcp-proxy
        restart: unless-stopped
        network_mode: "host"
        volumes:
            - ./caddy.json:/etc/caddy/caddy.json:ro
        command: ["caddy", "run", "--config", "/etc/caddy/caddy.json"]

There are a few things I want to note. The first is the network_mode. Docker, by default only listens to ipv4. There are ways to enable ipv6, but frankly, I got frustrated with it, so I bound its network to the host. This has 1 disadvantage: You no longer have the network isolation that docker provides. It binds the ports in your container to the host.

The second thing I want to note is that you'll need to configure the config Caddy should load. By default, it'll look for a file in /etc/caddy/Caddyfile. The default caddy container (caddy:2.10-alpine) already contains this file, so it'll always attempt to load this. By modifying the starting command, we can give it the caddy.json we've created earlier.

Testing your Minecraft server

After you've configured your DNS, set up your proxy server, and your game server, you're ready to test your Minecraft server. In the game, you should now see this show up:

My self-hosted MC server

And the best part? The Minecraft server is running on your own server, where you have full access, your friends can join you on your server without having to install VPN software, and you don't have to expose your home server to the internet, you don't have to forward any ports in your router, and your IP address stays a secret.

The only IP address that will become public knowledge is the proxy server's IP address. If you're paying Cloudflare member, you can even proxy raw TCP, as an additional layer of security.

Posted on: September 25th, 2025

I streamline your business with software that actually fits the way you work.

Ready to streamline your business? Let’s chat.

Roelof Jan Elsinga

Stay up-to-date on my blog posts

* indicates required

Please select all the ways you would like to hear from Roelof Jan Elsinga:

You can unsubscribe at any time by clicking the link in the footer of our emails.