Well the title should really say exposing arbitrary TCP or UDP ports to the internet as this is basically what is being done here.
Motivation
After exposing my HTTP web servers to the internet, I decided what else can I proxy via my virtual machine. How about.. my Minecraft server? :o
Like before, at the end of this procedure I discovered an easier SaaS approach, which I talk about at the end. If you want a simple one-program-does-it-all solution, just skip to there. If not.. I hope you find this article educational!
The Plan
Please consult The Diagram™️ for a second:
We currently have a use case where a user can connect to the home server which is running a minecraft server inside Docker. We want other people to connect too, and we can accomplish this via zrok.
The zrok tunnel part really comes down to 2 main components: share
and access
. Our home server is going to share to the zrok tunnel a resource (minecraft), and the public virtual machine will access it and expose it publicly. All will also need to be coordinated via zrok API itself. All 3 parts will be discussed individually.
Setting up zrok
If zrokNET STARTER plan limitations are acceptable, this step can be skipped almost entirely, just make sure you have an account you can zrok enable
with.
Personally I went with self-hosted approach, mostly as a challenge. The Docker self-host guide was excellent and got me to set up most of the system. I never ended up doing caddy https setup, as I already had certificate manager of traefik
installed on the host, and the whole public share system, while convenient, was not needed.
If you follow the self-hosted approach, follow the guide till you create an account.
Customizations of the compose.yml
What guide doesn’t tell you is that you need to expose the API to the internet for users to connect. That can be done by setting ZROK_INSECURE_INTERFACE=0.0.0.0
in the .env
file, or, what i did, was use traefik to expose the service with a TLS certificate.
As a quick refresher, my traefik configuration is as follows:
# ./compose.yml
services:
reverse-proxy:
image: traefik:v3.1
ports:
- "443:443"
- "80:80"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./traefik:/etc/traefik
- ./letsencrypt:/letsencrypt
networks:
- public
restart: unless-stopped
networks:
public:
external: true
name: public
# ./traefik/traefik.yml
entryPoints:
web:
address: ":80"
websecure:
address: ":443"
accessLog: {}
providers:
docker:
exposedbydefault: false
network: public
file:
directory: /etc/traefik/providers
watch: true
tls:
options:
default:
sniStrict: true
certificatesResolvers:
main:
acme:
email: YOUR_CONTACT_EMAIL
storage: /etc/traefik/acme.json
httpchallenge:
entrypoint: web
The main addition here is that I added a docker
provider to my traefik setup, and linked everything over a new external network public
(created with docker network create public
). This way I can proxy requests across docker compose projects.
With that being said, this is the addition I did to the zrok compose.yml
file:
# Changed zrok-controller.networks section to:
networks:
zrok-instance:
aliases:
- zrok.${ZROK_DNS_ZONE}
public:
# Added zrok-controller.labels section:
labels:
- "traefik.enable=true"
- "traefik.http.routers.whoami.rule=Host(`api.zrok.gedas.dev`)"
- "traefik.http.routers.whoami.entrypoints=websecure"
- "traefik.http.routers.whoami.tls.certresolver=main"
- "traefik.http.services.zrok.loadbalancer.server.port=18080"
# Updated the end of the file networks config to:
networks:
zrok-instance:
driver: bridge
public:
external: true
name: public
After doing all this, my api was openable on https://api.zrok.gedas.dev/
Setting up the private access
Now that we have an account and we have a token to enable environments, the rest is going to be a cake walk!
This section is based from this guide.
share
server
I downloaded the compose.yml
file given by the guide, and edited it to be the following:
services:
zrok-init:
image: busybox
# matches uid:gid of "ziggy" in zrok container image
command: chown -Rc 2171:2171 /mnt/.zrok
user: root
volumes:
- ./zrok_env:/mnt/.zrok
# enable zrok environment
zrok-enable:
image: ${ZROK_CONTAINER_IMAGE:-docker.io/openziti/zrok}
depends_on:
zrok-init:
condition: service_completed_successfully
entrypoint: zrok-enable.bash
volumes:
- ./zrok_env:/mnt
environment:
HOME: /mnt
ZROK_ENABLE_TOKEN:
ZROK_API_ENDPOINT:
ZROK_ENVIRONMENT_NAME: docker-minecraft-host
zrok-share:
image: ${ZROK_CONTAINER_IMAGE:-docker.io/openziti/zrok}
restart: unless-stopped
entrypoint: zrok-share.bash
depends_on:
zrok-enable:
condition: service_completed_successfully
volumes:
- ./zrok_env:/mnt
environment:
# internal configuration
HOME: /mnt # zrok homedir in container
# most relevant options
ZROK_UNIQUE_NAME: # name is used to construct frontend domain name, e.g. "myapp" in "myapp.share.zrok.io"
ZROK_BACKEND_MODE: # web, caddy, drive, proxy
ZROK_TARGET: # backend target, is a path in container filesystem unless proxy mode
ZROK_INSECURE: # "--insecure" if proxy target has unverifiable TLS server certificate
ZROK_BASIC_AUTH: # username:password
ZROK_PERMISSION_MODE: # if "closed" allow only your account and additional accounts in ZROK_ACCESS_GRANTS
ZROK_ACCESS_GRANTS: # space-separated list of additional zrok account emails to grant access in closed permission mode
# least relevant options
ZROK_VERBOSE: # "--verbose"
ZROK_SHARE_OPTS: # additional arguments to "zrok reserve private" command
ZROK_FRONTEND_MODE: reserved-private
PFXLOG_NO_JSON: "true" # suppress JSON logging format
# demo server you can share with zrok
minecraft:
image: itzg/minecraft-server
environment:
EULA: "TRUE"
volumes:
- ./mc-data:/data
It is largely unchanged, except I changed services.zrok-enable.environment.ZROK_ENVIRONMENT_NAME
to be docker-minecraft-host
. This is optional to change, but will be easier to identify on the API.
The other (most important) change is I added the actual minecraft server here to share. It could be placed in another compose file, but I would group these 2 things together.
My .env
file:
ZROK_ENABLE_TOKEN="YOUR_ACCOUNT_TOKEN"
ZROK_API_ENDPOINT="https://api.zrok.gedas.dev"
ZROK_UNIQUE_NAME="minecraft1"
ZROK_BACKEND_MODE="tcpTunnel"
ZROK_TARGET="minecraft:25565"
Let me explain this file:
ZROK_ENABLE_TOKEN
, this is token you got when you created the account. This can also be seen on API on the top-right context menu.ZROK_API_ENDPOINT
, optional when not self-hosting, this is the URL to the zrok API.ZROK_UNIQUE_NAME
, container share key. This can be any lowecase string, but it should be predictable, as it will be used on the receiver side.ZROK_BACKEND_MODE
, there are many backend modes, such ashttp
,udpClient
, but for minecraft server, we need to set totcpTunnel
.ZROK_TARGET
, this is tunnel destination. Becauseminecraft
is on the same compose file, meaning same network too, we can target it directly.
Running the compose file up, will create both minecraft server and will share it to the zrok network.
IMPORTANT To clean up after yourself, before shutting down the environment for the final time, run
sudo docker compose exec zrok-share zrok disable
, beforesudo docker compose down
. If you forget, no big deal, you can manually clean up in the API UI.
access
server
We are almost done, just left to set up the access.
This time the docker compose file was changed even less!
services:
zrok-init:
image: busybox
# matches uid:gid of "ziggy" in zrok container image
command: chown -Rc 2171:2171 /mnt/.zrok
user: root
volumes:
- ./zrok_env:/mnt/.zrok
# enable zrok environment
zrok-enable:
image: ${ZROK_CONTAINER_IMAGE:-docker.io/openziti/zrok}
depends_on:
zrok-init:
condition: service_completed_successfully
entrypoint: zrok-enable.bash
volumes:
- ./zrok_env:/mnt
environment:
HOME: /mnt
ZROK_ENABLE_TOKEN:
ZROK_API_ENDPOINT:
ZROK_ENVIRONMENT_NAME: docker-minecraft-proxy
zrok-access:
image: ${ZROK_CONTAINER_IMAGE:-docker.io/openziti/zrok}
restart: unless-stopped
command: access private --headless --bind 0.0.0.0:9191 ${ZROK_ACCESS_TOKEN}
depends_on:
zrok-enable:
condition: service_completed_successfully
ports:
- 25565:9191 # expose the zrok private access proxy to the Docker host
volumes:
- ./zrok_env:/mnt
environment:
HOME: /mnt
PFXLOG_NO_JSON: "true"
The only changes here are, again, services.zrok-enable.environment.ZROK_ENVIRONMENT_NAME
to be something more descriptive, and services.zrok-access.ports
to select a custom port (22565) to listen to.
The .env
file is similar to one in step before:
ZROK_ENABLE_TOKEN="YOUR_ACCOUNT_TOKEN"
ZROK_API_ENDPOINT="https://api.zrok.gedas.dev"
ZROK_ACCESS_TOKEN="minecraft1"
This is where the ZROK_UNIQUE_NAME
we defined in previous step comes into play. Just make sure ZROK_ACCESS_TOKEN
matches the value and it will work. The ZROK_ENABLE_TOKEN
, and ZROK_API_ENDPOINT
, work identically to previous step.
Results
When all docker compose projects are up and running, opening minecraft shows that everything works great!
When looking at the API, this is what you should see:
This graph shows:
- Your user.
- The host and proxy docker containers.
- The shared minecraft server resource.
- The accessed resource via tunnel (dotted line shows the bridge via zrok).
Can we do better?
Why yes, of course we can. We actually have 2 main “simple” cases.
SSH Tunnel
If you have a virtual machine you SSH into, you might as well just set up an SSH tunnel. In the past this is what I used to do and it worked out great.
An amazing explanation of SSH tunnels can be found on Ivan Velichko’s blog.
However if you want a quick and easy solution to host a server, heres a quick command to get you going. Run this in the machine hosting the server.
ssh -R 0.0.0.0:25566:localhost:25565 user@myhost.xyz
This command sets up so requests coming from any IP address (
0.0.0.0
), hitting the VM’s25566
port, get tunneled to local machine’s port25565
. Adjust ports as needed. Also if your server is on another host, replacelocalhost
too.
If you cannot connect to the minecraft server after this, check /etc/ssh/sshd_config
file, and make sure GatewayPorts
is set to clientspecified
or yes
(not recommended) (source).
It does have some problems though:
- It only woks with TCP traffic. There are janky solutions to forward UDP over TCP, however I didn’t even attempt it.
- Tunnels eventually close after there has been no activity on them. A tool called
autossh
seems to work great to bypass (thread), but I wanted a more ‘Docker’ approach.
playit.gg
Another service we can make use of is playit.gg. They offer proxy services for arbitrary tcp/udp traffic.
I have a blog post about it here: link.