baucord Hosting a server

Hosting a server

One person runs the server; everyone else connects to it. It is a single program on one UDP port: no website, no domain name and no certificate to set up.

What you need

Every connection is encrypted (QUIC over UDP) and checked against the server's own key, which it makes on first start, so there is no certificate to buy or renew.

From the download

Every desktop download (Windows, macOS, Linux) contains the app and the server, dedicated-server.

  1. Download the zip for the computer that will host, and unzip it into a folder you will keep. The server writes its files into the folder it is started from, so start it from there: on Windows, double-click it; on macOS and Linux, cd into the folder and run ./dedicated-server.
  2. Start it (on Windows it is dedicated-server.exe; allow it through the firewall when Windows asks). On first start it writes server.yaml with the defaults and creates data/baucord/, where its key and database live.
  3. Open UDP port 51000 (see above). To use another port, change port in server.yaml and restart.
  4. Look at what it printed. Two lines matter:
    Address for clients: <public-ip>:51000 — identity SHA256:…
    No admin yet. Bootstrap invite (admin, 1 use, 1 h): code K7FQ-2MX9-ABCD for …
    Set public_address in server.yaml to your public IP and port, and the first line will show it. The second is your way in; the next section says what to do with it.
  5. Keep it running: a terminal window left open is enough, or your system's service manager. Stopping it (Ctrl+C) tells everyone connected that the server is restarting.

Becoming admin, inviting people

A device's key is its account: nobody has a password. A device the server does not know yet needs an invite code once; after that it just connects.

  1. You first. While the server has no admin, it prints a one-time admin code at every start (good for one use, for an hour). In your app: Settings → Servers, add the server's address, paste the code into Invite code, and connect. You are now its admin.
  2. Everyone else. Type /invite in the chat box, or open the admin panel. The code is copied for you; send it together with the address and port. Options: /invite [guest|member|organizer] [24h|7d] [uses].
  3. Optional: the identity in the Address for clients line can go into Settings → Servers too, so the app checks it is talking to your server before it sends anything. Without it, the app learns the identity on the first connection.

Roles go from guest to member, organizer and admin, and you can make your own. A code says which role it gives; change people's roles later from the admin panel.

On a Linux host with Docker

Docker Compose on a Linux VPS, with the published image mpwsh/baucord:latest, updated with every release. It is distroless and runs as uid 10000. Everything you need is on this page: four small files and the image, which Docker downloads by itself.

The image, like the Linux download, is for x86-64 machines. ARM hosts (a Raspberry Pi, an ARM VPS) are not supported yet.

client ──udp/51000 (QUIC)─▶ baucord   (published directly)

One service. Media leaves straight from the container, not through a userspace proxy, which would add copies per packet and hide the real client addresses from the server.

The files

Four files, all downloadable from here. Read a script before you run it as root.

File Goes to What it is
docker-compose.yaml docker-compose.yaml The one service: image, port, volumes, log rotation
baucord.yaml config/baucord.yaml The server's configuration, to edit (Configuration)
setup.sh scripts/setup.sh Sets up the host and starts the server; safe to run again
monitor-net.sh scripts/monitor-net.sh Logs where UDP packets get dropped (Monitoring)

The compose file:

# One service: the relay. Everything — media, chat, admission — rides
# one QUIC port over UDP. config/baucord.yaml is this host's own config
# (start from https://baucord.com/deploy/baucord.yaml); ./data/baucord holds the
# server's identity key and database: back it up. The guide, and this
# file to download: https://baucord.com/deploy.html
services:
  baucord:
    container_name: baucord
    # Published on every release (x86-64). Update: docker compose pull,
    # then docker compose up -d (or run scripts/setup.sh again).
    image: mpwsh/baucord:latest
    restart: always
    ulimits:
      nofile:
        soft: 65536
        hard: 65536
    ports:
      # The same number as server.port in config/baucord.yaml. Moving a
      # server off another port: publish both while saved addresses
      # catch up, e.g. also "5000:51000/udp".
      - "51000:51000/udp"
    volumes:
      - ./config/baucord.yaml:/app/server.yaml
      - ./data/baucord:/app/data
    # The server logs to stderr here, so this is its log: kept to
    # 5 × 10 MB instead of growing for ever (it names people and IPs).
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "5"
    networks:
      baucord:

networks:
  baucord:

Next to them, the server creates data/baucord/: its identity key, database, shared files and the logs people send. Back it up.

Setting up a host

  1. Allow udp/51000 in the provider's firewall. Nothing else: no DNS record, no TCP port, no certificate.
  2. Put the files in a folder of their own, for example /opt/baucord:
    sudo mkdir -p /opt/baucord/config /opt/baucord/scripts
    cd /opt/baucord
    B=https://baucord.com/deploy
    sudo curl -fsSL -o docker-compose.yaml     $B/docker-compose.yaml
    sudo curl -fsSL -o config/baucord.yaml     $B/baucord.yaml
    sudo curl -fsSL -o scripts/setup.sh        $B/setup.sh
    sudo curl -fsSL -o scripts/monitor-net.sh  $B/monitor-net.sh
    sudo chmod +x scripts/*.sh
  3. In config/baucord.yaml, set server.public_address to the host's public IP and port, and the server's name and channels to taste (Configuration).
  4. Run the setup script:
    sudo scripts/setup.sh
    It is safe to run again after any change. It:
    • installs Docker and the compose plugin from the distribution's packages, only if they are missing;
    • creates ./data/baucord owned by uid 10000, where the server makes its identity key on first start;
    • tunes the host for UDP fan-out: socket buffer limits and the kernel's receive backlog (/etc/sysctl.d/99-baucord-udp.conf), connection tracking sized for Docker's port publishing, NIC ring buffers and RPS at boot, and no cloud-init network hotplug;
    • pulls the image and starts the server (docker compose pull, docker compose up -d);
    • prints the server's address and identity, and on a new server the one-time admin invite code;
    • installs the network monitor as a service (Monitoring).
    The settings at the top of the script (the port, which it reads from your config; MEDIA_VIA_DOCKER_NAT; the uid; the network interface, detected) are the only things you should ever need to touch.
  5. Check that it came up:
    sudo docker compose ps
    sudo docker compose logs -f baucord
    ss -uln | grep :51000
    The address and the admin code are in the log too:
    sudo docker compose logs baucord | grep -E 'Address for clients|Bootstrap invite'

Host networking (optional)

For the last bit of headroom, run the service with network_mode: host instead of ports:. That takes Docker's address translation and connection tracking out of the media path. Then set MEDIA_VIA_DOCKER_NAT="false" in scripts/setup.sh and run it again: it adds a notrack rule for the media port, which is only safe without Docker's translation.

Configuration

One YAML file: server.yaml next to the server from the download, config/baucord.yaml with Docker. The parts you will touch:

server:
  name: My baucord server
  port: 51000                          # UDP
  public_address: "203.0.113.5:51000"  # printed for people to type
  greeting_message: Welcome!
identity:
  dir: data/baucord                    # the identity key: back it up
logging:
  file: server.log                     # "" = stderr only (Docker)
  link_stats_interval_secs: 0          # per-client link: lines (2 while diagnosing)
  telemetry: true                      # log clients' telemetry: samples
persistence:
  history:   { retain_days: 90 }       # chat and direct messages
  files:     { retain_days: 30 }       # files nothing uses any more
  telemetry: { retain_days: 30 }       # clients' 10-second samples
channels:
  - name: General
  - name: Gaming
    password: "hunter2"                # optional
    user_limit: 8                      # optional

Managing from the command line

Most things are in the app's admin panel. The same tools work from the server's command line, also while it runs (it picks changes up within two seconds). From the download: ./dedicated-server <command>. With Docker:

cd /opt/baucord
sudo docker compose exec baucord /app/server <command>
Command What it does
invite create --grants member --expires 7d --uses 5 A new code (role: guest by default; expires: 24h by default, or never; uses: unlimited by default)
invite list, invite revoke CODE Every code with its state; stop one admitting anyone else
user list Everyone with their roles, keys and when they were last seen
user grant NAME admin, user revoke NAME ROLE Add or remove a role (the last admin keeps admin)
user kick NAME, user ban NAME --for 7d, user mute NAME Kick (they need a new invite), ban (their device keys; a ban from the app also blocks their address while they are online), mute (their voice is dropped); all take --reason
user purge NAME Delete a person: account, keys, roles and messages
role list, role create … Custom roles: named sets of permissions
backup FILE.tar, restore FILE.tar See backups
rotate-key A new identity key (with the server stopped)
db check, db prune Check the database; run the retention clean-up now

--help after any command lists all of its options.

The identity key and backups

On first start the server makes an identity key in data/baucord/. Everyone's app remembers it: lose it and every app will refuse the server as an impostor. Keep a copy of the whole directory: the key, the database (accounts, roles, invites, messages) and the files people shared.

A consistent backup, safe while the server runs:

# from the download
./dedicated-server backup baucord-2026-10-08.tar
# with Docker (the file lands in ./data/baucord)
sudo docker compose exec baucord /app/server backup /app/data/baucord-2026-10-08.tar

To put one back, stop the server and run restore FILE.tar (with Docker: sudo docker compose stop baucord, then sudo docker compose run --rm baucord restore /app/data/FILE.tar). The database it replaces is kept beside it.

To change the key (if it may have leaked), stop the server and run rotate-key (with Docker: sudo docker compose stop baucord, sudo docker compose run --rm baucord rotate-key, sudo docker compose start baucord). The new key is vouched for by the old one, so apps move to it on their own.

Updating

A restart is a blink: the server tells every app it is restarting, and they reconnect on their own about two seconds later. Keep the server and the apps on the same version: when the connection format changes, an older app is turned away with a message to update rather than failing halfway through a call.

Reading the server log

From the download it is server.log (and the terminal); with Docker, sudo docker compose logs baucord in the server's folder. Two periodic lines carry most of what you will ever need:

Rising loss with a flat round-trip time is that person's network; a rising round-trip time with little loss is a queue somewhere, the server's or a router's.

Monitoring

A server is a UDP fan-out: a ~177 KB keyframe goes to every viewer at once, and the kernel drops such bursts silently. What you see is abandoned video groups and timeouts, not errors. On a Docker host, setup.sh runs scripts/monitor-net.sh as a service, baucord-monitor, which writes a CSV row every 10 seconds to /var/log/baucord-net.log (rotated weekly, about two months kept):

systemctl status baucord-monitor
tail -f /var/log/baucord-net.log

UDP counters are per network namespace, so it samples the host and the baucord container, one row each. Every *_err and *_drop column is the change since the last row, and note names the layer that dropped:

note meaning
RCVBUF Receive buffer overflow: the server is not draining it, or rmem is small
SNDBUF Send buffer overflow: a keyframe burst bigger than wmem, or the link is full
SOFTNET Kernel backlog overflow: netdev_max_backlog, or out of CPU
NIC The NIC's ring, or the provider's packet or bandwidth allowance: see ethtool -S
VETH Drops on Docker's bridge
COUNTER_RESET The container restarted

max_txq_bytes creeping towards wmem_max during keyframes means SNDBUF drops are close: pace keyframes rather than raising the cap. softirq_pct above about 30 % for long means packet processing is out of CPU. ct_count nearing ct_max means connection tracking is about to start refusing new people.

Your server, your data

Whoever runs a server holds what people send through it, and answers for it. The server stores accounts, roles, chat and direct messages, shared files and the apps' call-quality samples in data/baucord/, for as long as the persistence settings say; logs people send from their app land in data/baucord/logs/. Its own log records connections with IP addresses and the text of chat messages. Connections are encrypted but not end to end: the server can read what passes through it.

Tell the people on your server what you keep and for how long. The privacy policy of the official server lists everything a server receives and is a fair starting point.