I ran my Hytale server as a plain systemd unit for months and it was fine. Start, stop, journalctl -f, done. What finally moved me to Docker with a panel in front wasn't the server — it was everyone else. Every restart, every config tweak, every "can you check the log" went through me and my SSH key. That's a bottleneck with a person in it.

Now the server runs as a container, a panel container manages it, and the people who help me run the thing can restart it and read the console without touching a terminal. The systemd unit still exists — disabled, as a fallback — because I don't fully trust any panel to be the only way into my own server.

TL;DR — the shape:

panel container   :3000   → web UI, talks to the Docker socket
server container  :5520/udp → the actual Hytale server
volume            ./data   → world, config, logs (lives on the host)

If you're standing up Hytale for the first time, start with /hytale-server-install-guide-prep/ — this post is about what to do once it works and you want it manageable.

Why a panel and not just docker compose restart

Be honest about whether you need one. If you're the only admin, you don't — a compose file and a shell alias beat a panel on every axis. A panel earns its complexity when:

  • more than one person needs start/stop/console, and you don't want to hand out SSH,
  • you want the console readable without teaching people journalctl,
  • you want a big obvious "restart" button that doesn't rm -rf the world folder when someone fat-fingers it.

That's it. It's a permissions and ergonomics tool, not a performance one. The panel doesn't make the server faster; it makes the humans faster.

The compose file

services:
  panel:
    image: ketbom/hytale-panel:latest
    container_name: hytale-panel
    restart: unless-stopped
    ports:
      - "3000:3000"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - ./panel-data:/app/data
    environment:
      - PANEL_SECRET=${PANEL_SECRET}

  server:
    image: hytale-server:latest
    container_name: hytale-server
    restart: unless-stopped
    stop_grace_period: 60s
    ports:
      - "5520:5520/udp"
    volumes:
      - ./data:/data
    stdin_open: true
    tty: true

Four lines in there do real work, and three of them are easy to miss.

5520:5520/udp — Hytale's game traffic is UDP. Omit /udp and Docker cheerfully publishes TCP, the port scan looks right, and not a single player connects. This is the single most common "my server is up but nobody can join" cause I've seen.

stop_grace_period: 60s — the default is 10 seconds, after which Docker sends SIGKILL. A game server killed mid-save leaves you a corrupt world and a bad evening. Give it a full minute to flush.

stdin_open: true + tty: true — this is what keeps the server console attachable. Without them, docker attach gives you nothing and the panel can't send commands into the process. You need both.

The Docker socket mount. Understand what you just did: the panel container can now control every container on the host, which is root-equivalent. That's how it starts and stops the server, and it's also why the panel must never be exposed to the internet unauthenticated. More on that below, because it's the part people get wrong.

The console problem

Hytale's server wants an interactive console — for auth, for admin commands, for anything you'd normally type at the prompt. In a container, "the console" is stdin of PID 1, and getting at it is fiddlier than it looks.

docker attach hytale-server works, and then you accidentally hit Ctrl+C and kill the server, because attach forwards signals. Detach is Ctrl+P Ctrl+Q, which nobody remembers under pressure.

The safer route is a FIFO. Point the server's stdin at a named pipe on the host and write commands into it:

mkfifo /tmp/hytale-console
# feed the pipe into the container's stdin
docker run -i ... < /tmp/hytale-console

Then sending a command is just:

echo "auth login device" > /tmp/hytale-console

Now you can script it, the panel can write to it, and nobody's Ctrl+C takes down the server. Read the output separately with docker logs -f hytale-server — output and input are decoupled, which is exactly what you want.

The device-code auth flow is the one thing that still needs a human: kick it off on the console, watch the logs for the code and URL, approve it in a browser. Once. The token persists in the volume — which is a good reason for the volume to be on the host and in your backups, not in an anonymous Docker volume you'll delete by accident.

Locking the panel down

The panel talks to the Docker socket. Anyone who gets into the panel effectively has root on the host. So:

Don't publish :3000 to the world. Bind it to localhost and reach it through a reverse proxy with TLS and auth:

ports:
  - "127.0.0.1:3000:3000"

Then front it with nginx (/nginx-reverse-proxy-setup/) or Caddy, add a cert, and put HTTP basic auth in front of the panel's own login as a second layer. Belt and braces — panels are young software and I do not want to be the one who finds their auth bypass.

Or don't expose it at all. My preferred setup: the panel binds to a WireGuard address and is only reachable to people on the VPN. Zero public attack surface, and handing someone access is wg set peer ... instead of a password they'll reuse.

Firewall the rest. Only 5520/udp should be publicly open. Docker famously writes its own iptables rules and will punch through UFW without asking — a ports: mapping is a hole in your firewall whether UFW knows about it or not. Bind to 127.0.0.1 for anything that isn't the game port, and check with ss -tulnp what's actually listening on the public interface. Trust the socket table, not your rules file.

Backups, since the container makes it easy

The whole world is in ./data on the host. So:

#!/bin/bash
set -euo pipefail
STAMP=$(date +%F-%H%M)
docker exec hytale-server sh -c 'echo "save-all" > /proc/1/fd/0' || true
sleep 10
tar czf "/backup/hytale-$STAMP.tar.gz" -C /opt/hytale data
find /backup -name 'hytale-*.tar.gz' -mtime +7 -delete

Flush the world before you tar it, or you're backing up a half-written save. || true so a failed flush doesn't abort the backup entirely — a slightly-stale backup beats no backup. Run it on a timer, not a cron job, if you want the failures to show up in journalctl: /systemd-timers-instead-of-cron/.

And restore-test it. An untested backup is a folder of hope. I restore into a throwaway container every few weeks; it takes five minutes and it's the only way to know the tarball isn't empty.

Keep the systemd unit — disabled

This is the bit I'd argue for hardest. When I moved to Docker I didn't delete the old unit. I ran:

systemctl disable --now hytale

It's still there. If the panel breaks, if Docker eats itself, if I upgrade something and the container won't start at 1am, I can systemctl start hytale against the same data directory and be back up in thirty seconds while I debug the fancy stuff in daylight.

Two ways to run the same thing, one of them off, both working. That's not indecision — that's the cheapest disaster recovery you'll ever write.

Just don't ever run both at once. They'll both bind 5520/udp and both write the same world files, and the resulting corruption is entirely your own fault. Ask me how I know that one too.


Related posts