Hanish Rao’s Garden
Notes/guide

Self-hosting Xonotic with Docker for LAN games

buddingtools#homelab

I remember playing Xonotic over LAN at college. Hosting it again is a way to bring those matches back: a few computers on the same network, a map, and someone to shoot at.

Xonotic is a free, open-source arena shooter. This guide starts with a quick LAN game, then runs a dedicated server with Docker Compose so it keeps running when the host player leaves. We build our own container from the official release, keep configuration outside the image, and publish only the game port.

The quickest LAN match

Install the same current Xonotic release on everyone's computer. On one computer, open Multiplayer → Create, choose a map and game type, and start the match. Other players connect to that computer's LAN address.

This is a listen server: the host also plays. For a separate machine that hosts without a player, use the dedicated setup below. The default game port is UDP 26000, not TCP. Official server FAQ.

Use Ethernet where possible, and make sure the players are on a network that permits communication between devices. Guest Wi-Fi and access-point client isolation can prevent a LAN match even when everyone has internet access.

Prepare the Docker project

Use an x86-64 Linux host with Docker Engine and the Compose plugin. This example packages Xonotic 0.8.6, the release currently linked on the official download page. The roughly 1.2 GB archive includes the game assets. Allow several GB of disk space for downloading, extracting, and building the image. This recipe targets linux/amd64; it is not a native ARM image.

sudo install -d -o "$(id -u)" -g "$(id -g)" /srv/xonotic
cd /srv/xonotic
# The container runs as UID/GID 10001.
sudo install -d -o 10001 -g 10001 -m 0750 state state/data
mkdir -p backups

Create .env with the actual IPv4 address assigned to your Docker host's LAN interface. Reserve that address in DHCP so it does not change:

XONOTIC_BIND_IP=192.168.1.50

Create .dockerignore so runtime state and backups never enter the image build context:

state/
backups/
.env
.git/

Dockerfile

Save this as /srv/xonotic/Dockerfile. The build downloads the official ZIP, verifies its pinned SHA-512 checksum, and copies the extracted release into a separate runtime stage. The checksum below is from the official 0.8.6 checksum file.

FROM debian:bookworm-slim AS download
ARG XONOTIC_VERSION=0.8.6
ARG XONOTIC_SHA512=cb39879e96f19abb2877588c2d50c5d3e64dd68153bec3dd1bebedf4d765e506afa419c28381d7005aed664cb1a042571c132b5b319e4308cab67745d996c2a6
RUN apt-get update \
    && apt-get install -y --no-install-recommends ca-certificates curl unzip \
    && rm -rf /var/lib/apt/lists/*
WORKDIR /tmp/download
RUN curl --fail --location --retry 3 \
      "https://dl.xonotic.org/xonotic-${XONOTIC_VERSION}.zip" -o xonotic.zip \
    && echo "${XONOTIC_SHA512}  xonotic.zip" | sha512sum --check --strict - \
    && unzip -q xonotic.zip -d /opt \
    && chmod +x /opt/Xonotic/xonotic-linux64-dedicated
 
FROM debian:bookworm-slim
RUN apt-get update \
    && apt-get install -y --no-install-recommends \
      ca-certificates libsdl2-2.0-0 libvorbisfile3 libcurl4 libpng16-16 libgmp10 \
    && rm -rf /var/lib/apt/lists/* \
    && groupadd --gid 10001 xonotic \
    && useradd --uid 10001 --gid 10001 --home-dir /state \
      --no-create-home --shell /usr/sbin/nologin xonotic \
    && install -d -o 10001 -g 10001 /state/data
COPY --from=download /opt/Xonotic /opt/Xonotic
WORKDIR /opt/Xonotic
ENV HOME=/state
USER 10001:10001
EXPOSE 26000/udp
ENTRYPOINT ["./xonotic-linux64-dedicated"]
CMD ["-userdir", "/state", "-sessionid", "lan-server", "+serverconfig", "server.cfg"]

The release and checksum must be updated together when upgrading. Pin the Debian base image to a reviewed digest too if you need reproducible base layers. The -userdir and +serverconfig arguments follow the upstream dedicated-server instructions and launcher.

docker-compose.yaml

Save beside the Dockerfile:

name: xonotic-lan
 
services:
  xonotic:
    build:
      context: .
    image: local/xonotic:0.8.6
    platform: linux/amd64
    restart: unless-stopped
    init: true
    stdin_open: true
    tty: true
    stop_grace_period: 30s
    ports:
      - "${XONOTIC_BIND_IP:?Set your host LAN IPv4 address in .env}:26000:26000/udp"
    volumes:
      - ./state:/state
    read_only: true
    tmpfs:
      - /tmp
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
    labels:
      - traefik.enable=false
      - com.centurylinklabs.watchtower.enable=false
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

The configuration below lives at state/data/server.cfg on the host and /state/data/server.cfg in the container. The writable bind mount preserves settings and runtime state when the container is recreated. Docker manages restarts; no separate systemd game service is needed.

server.cfg: a small LAN arena

From /srv/xonotic, create state/data/server.cfg:

sudo nano state/data/server.cfg
sudo chown 10001:10001 state/data/server.cfg
sudo chmod 640 state/data/server.cfg

Paste this starting configuration:

hostname "School LAN Revival"
sv_motd "Welcome back. Have fun, and give new players a chance."
sv_public 0
port 26000
maxplayers 12
 
// Start with deathmatch and bundled maps.
gametype dm
g_maplist "stormkeep warfare xoylent"
g_maplist_shuffle 1
timelimit_override 10
fraglimit_override 30
 
// Fill a quiet match with bots.
minplayers 4
skill 3
 
// Keep remote administration disabled for this setup.
rcon_password ""
sv_status_privacy 1
sv_weaponstats_file ""

minplayers 4 fills the match toward four participants using bots; it is not four bots in addition to all humans. Tune skill to your group. The final setting disables the weapon-statistics submission configured in the example server file. For more settings, start with the official example configuration.

sv_public 0 keeps the server off the master-server listing while allowing direct queries. It is not a firewall or admission control. A reachable host can still accept connections. Restrict UDP 26000 to your LAN/VPN in the host firewall, and do not configure an internet port-forward for a LAN-only server. Engine definition of server visibility.

The Compose mapping binds UDP 26000 to the specified host IPv4 address instead of all interfaces. That limits the published destination, but is not a source allowlist: routed networks may still reach it. Keep the router's WAN port-forward disabled for LAN-only play and use Docker-aware firewall policy if restricting source subnets. Docker-published ports can bypass ordinary UFW input rules, so a plain ufw allow/deny recipe is not sufficient here. Docker port publishing and firewall behavior.

Build, start, and join

From /srv/xonotic:

docker compose config --quiet
docker compose build --pull xonotic
docker compose up -d xonotic
docker compose logs -f --tail=100 xonotic

The first build downloads the release and runtime packages; later builds can reuse cached layers. Wait until the server loads a map, then connect from another computer running the same Xonotic release. Open the game console with Shift+Escape and enter your Docker host's address:

connect 192.168.1.50:26000

Use the host's LAN address, not a container IP or 127.0.0.1. LAN discovery may not cross a Docker bridge, so direct connection is the reliable starting point. Guest Wi-Fi client isolation can also block connections.

# Logs, resource use, and normal lifecycle operations:
docker compose logs --tail=100 xonotic
docker compose stats xonotic
docker compose restart xonotic
docker compose stop xonotic

After editing the mounted server.cfg, restart between matches. After changing .env or Compose settings, use docker compose up -d to recreate the container with those settings. Restarting alone does not apply a changed port mapping.

For the interactive server console, run docker compose attach xonotic; status shows players. Detach with Ctrl+P, then Ctrl+Q. Avoid Ctrl+C, which can signal the server. Use docker compose stop for an intentional shutdown; RCON remains disabled.

Mix up the matches

Change gametype in server.cfg, then restart between matches:

Value Game type
dm Deathmatch
tdm Team deathmatch
ctf Capture the flag
ca Clan arena
ft Freeze tag

When switching modes, also set g_maplist "" to let the server find maps suitable for that mode instead of keeping the small deathmatch selection. g_instagib 1 is another optional twist; restore g_instagib 0 for normal weapons. These are built-in settings, so no mod installation is needed for this starting setup. Configuration reference.

Begin with the bundled maps. Custom maps add a distribution step: clients need the assets, and automatic downloads need configuration. Follow the release's Docs/mapdownload.txt before adding a custom rotation.

Friends outside the LAN

For a private group, a VPN that lets the players reach the server is a useful extension of the LAN setup. Set XONOTIC_BIND_IP to the host's VPN IPv4 address, recreate the container with docker compose up -d, and connect to that address. Ensure the VPN interface exists before starting the container. Keep sv_public 0 if you do not want master-list advertising.

If you deliberately host over the internet behind a home router, use the host's LAN IPv4 address for the binding, forward UDP 26000 to it, and configure Docker-aware firewall policy accordingly. Set sv_public 1 only if you also want public discovery. A private IPv4 address behind carrier-grade NAT may require a VPN or a different hosting arrangement.

Xonotic gameplay is not HTTP. The HTTP routers in the Traefik guide do not carry this traffic. Direct UDP routing is enough for this setup; there is no browser console or TLS certificate to configure.

Maintenance and troubleshooting

Back up state/, Dockerfile, docker-compose.yaml, and .env before changing releases. Run this from /srv/xonotic between matches:

(
  set -e
  mkdir -p backups
  docker compose stop xonotic
  trap 'docker compose up -d xonotic' EXIT
  sudo tar -czf "backups/xonotic-$(date +%F-%H%M%S).tgz" \
    state Dockerfile docker-compose.yaml .env .dockerignore
)

The subshell restarts the container on exit, including if archiving fails; check the archive and command output before counting it as a successful backup. Copy backups off the host. To restore, stop the container, move the existing state aside, extract a trusted archive, and preserve UID/GID 10001 ownership. Rebuild the saved Dockerfile and start again.

For an upgrade, update the Dockerfile's release and checksum plus the Compose image tag, then run docker compose build --pull xonotic and docker compose up -d xonotic. Test first with a copy of the state, a distinct project name and host port, and a distinct local image tag. Keep clients and server on the same release. Avoid updating the game files inside a running container; those changes would disappear on recreation.

Problem Check
Nothing appears in the browser Try direct connect; public listing is disabled and discovery may not cross the bridge
Connection times out .env host address, UDP mapping, Docker-aware firewall, server logs, and Wi-Fi isolation
Cannot assign requested address XONOTIC_BIND_IP must exist on the Docker host; check DHCP or VPN interface state
Address already in use Another container or native server may already publish/listen on UDP 26000
Permission denied in /state Host state/ and its contents need to be writable by UID/GID 10001
Defaults load instead of your settings Use state/data/server.cfg, not state/server.cfg
Exec format error This recipe uses the x86-64 release; ARM needs a suitable build or configured emulation
A new mode has no usable maps Reset g_maplist and use bundled maps before adding custom ones
You keep getting beaten by bots Lower skill, or use minplayers 0 for human players only

References