← Back to Wiki
Self-Hosting / Chat & Voice

Self-Hosting TeamSpeak 6 (Beta) in Docker

TeamSpeak 6's Docker image is a clean install. The real time goes into the parts nobody documents well. Bootstrapping API access without a chicken-and-egg deadlock. Building AFK auto-move and mute yourself, since the server will not do it natively. And exposing voice and file transfer publicly without silently breaking one of them.

Share on X

Why TS6 over classic TS3

A native TS3 install works, with systemd and ts3server_startscript.sh. It has one real annoyance worth knowing even if you stay on TS3. The server phones home to TeamSpeak's accounting service roughly hourly, and a failed check-in triggers a clean self-shutdown at exit code 0. The exit is clean, so systemd's default Restart=on-failure does not catch it. The server goes down silently and stays down until someone notices. If you stay on TS3, set Restart=always in the unit file to close that gap. TS6 is labelled beta and has been stable for us. It does not have this issue, and it is the newer codebase. Better starting point for a fresh deploy.

The Docker deployment

The official image is teamspeaksystems/teamspeak6-server:latest. Run it with network_mode: host rather than mapping each port individually. TeamSpeak uses several. Voice UDP 9987, file-transfer TCP 30033, plus whichever query ports you enable. Host networking saves tracking them all in the compose file.

services:
  teamspeak6:
    image: teamspeaksystems/teamspeak6-server:latest
    container_name: teamspeak6
    network_mode: host
    environment:
      - TSSERVER_LICENSE_ACCEPTED=accept
      - TSSERVER_QUERY_HTTP_ENABLED=1   # WebQuery, port 10080
      - TSSERVER_QUERY_SSH_ENABLED=1    # SSH-query, port 10022
    volumes:
      - ./data:/var/ts3server
    restart: unless-stopped

Query interfaces are off by default. You need the two env vars above for any admin or scripting access at all. Leave raw telnet query on port 10011 disabled. SSH-query covers the same capability more safely.

Grab the ServerAdmin credentials from the startup log immediately. They print once, on first boot. docker logs teamspeak6 shows loginname="serveradmin" and a generated password. Newer images are supposed to print an apikey= line too, which skips the bootstrap step below. That line was not present in our own startup log, and it may be version-dependent. Check before you assume you have to mint one by hand.

Bootstrapping API access: the chicken-and-egg problem

WebQuery is the convenient HTTP API for building out channels, permissions and bots. WebQuery itself requires an API key to do anything, including generating a new one. Bootstrap through SSH-query first:

ssh -p 10022 -o StrictHostKeyChecking=no [email protected] <<EOF
apikeyadd scope=manage lifetime=0
quit
EOF
BE WARNED: SSH-query is a stateful interactive session, not a one-shot exec. Send commands as a heredoc into an interactive shell. ssh ... 'command' style single-exec calls fail with exec request failed on channel 0. It also requires an explicit use <serverId> before any per-virtualserver command, unlike WebQuery, which encodes the server ID in the URL path as /1/<command>. And the query interface's IP allowlist defaults to 127.0.0.1 and ::1 only. Run this from inside the container or host, not over the LAN.

Once you have a WebQuery API key, standard channel creation and editing is clean HTTP:

curl -sS -G -X POST 'http://127.0.0.1:10080/1/channelcreate' \
  --data-urlencode "api-key=$API_KEY" \
  --data-urlencode 'channel_name=General' \
  --data-urlencode 'channel_flag_permanent=1'
BE WARNED: file-transfer commands are entirely excluded from WebQuery's scope model, and it is undocumented. Every ft* call over WebQuery returns {"code":5120,"message":"out of scope"}, even with a manage-scope key. That is almost certainly by design. WebQuery is stateless per-request HTTP, and ftinitupload hands off to a stateful raw TCP socket, which does not fit the model. Use SSH-query, or raw query, for anything file-transfer related.

If the ServerAdmin account ever needs recovering

Unlike classic TS3, TS6 does not auto-regenerate a fresh serveradmin account when the existing one gets deleted. Delete it expecting a self-heal and it logs Could not find an account which has the 'client unique id' of 'serveradmin' and moves on, leaving no way to query the server at all. The real recovery path from there is inserting a new row directly into tsserver.sqlitedb's clients table, hashed the way TS stores it. base64(sha256(password)), a 44-character base64 string matching the original row's format. Back the database up first. Then log in via SSH-query specifically. WebQuery's own docs list login and logout as unsupported over HTTP, so you cannot skip straight to WebQuery from a cold start.

Save any query credential somewhere durable the moment you mint it. A ServerQuery or WebQuery credential generated once, used, then lost, with no supported lookup path, drops you straight back into the recovery flow above for no reason. A password manager, not a scratch note.

Auto-move + auto-mute for an AFK channel (auto-deafen is not possible)

The common ask. New connections land in an AFK holding channel. Idle users get moved there automatically. Speaking and listening both get disabled for anyone inside it. Here is what is achievable server-side:

An AFK-mover bot is a short, dependency-free script. Poll clientlist -times via WebQuery every 30 seconds. Move anyone idle past your threshold into the AFK channel with clientmove. Skip ServerQuery connections themselves, client_type=1, so the bot does not try to move its own session. Run it as a small systemd service with Restart=always, ordered After=docker.service.

Exposing it publicly: voice and file transfer need matching ports

TeamSpeak needs two separate tunnels sharing one public exposure point, because voice and file transfer are genuinely different transports. UDP 9987 for voice. TCP 30033 for file transfer, meaning avatars, channel icons and chat uploads.

BE WARNED: a NAT-traversal tunnel that remaps ports breaks file transfer, even though voice works fine over it. TeamSpeak's file-transfer protocol embeds a literal port number in its own application data. ftinitupload's response includes port=30033, the server's real internal port, and the client dials that exact port against whatever hostname it connected through. Any tunnel provider that remaps external to internal ports leaves the client connecting to a port nothing is listening on externally. Most consumer tunnel services remap, as an architectural property of how they multiplex connections. It is not a config option you can turn off. Voice keeps working the whole time, which makes this easy to miss until someone tries to upload an avatar or a channel icon and it hangs.

The fix is a tunnel that supports exact port matching end to end. A self-hosted frp instance on a cheap VPS is the reliable option. Point remotePort at exactly localPort for both UDP 9987 and TCP 30033, with no remapping in either direction. Then verify file transfer specifically. Do not assume "voice works" means the tunnel is correct. The real test is an image or icon upload attempt, watched live with docker logs -f on the server side. If the tunnel still remaps ports you will see zero server-side log activity for the failed attempt. That is the tell that the connection never reached the container. Not a permissions or size-limit issue. A NAT-traversal one.

A confirmed upstream bug, and how to know it's not yours

Channel or server icon assignment can fail with invalid parameter no matter what you try. WebQuery, SSH-query, the real GUI client, which reports a misleading "insufficient permissions" error for the same underlying failure. Signed against unsigned icon-ID representation. Even setting the field to its own current unchanged value as a total no-op. That is a real bug in this TS6 beta build, not a local misconfiguration. The conclusive test is that no-op write. If setting a property to the value it already holds still fails, permissions and data validity are both ruled out, and what is left is the server's own handling of that field. TeamSpeak Server is closed-source, so you cannot patch it locally. It needs an upstream fix. Rule out the usual suspects first. The permission group has the relevant flag. The file size is under the server's configured limit. The field name matches what the server reports back. Once you have, file it and move on rather than spending more time on your own config.

Eventually moving on

This setup ran well for voice for months before we replaced it with Outpost, a self-hosted Discord alternative covering voice and text in one place with a modern web, desktop and mobile client. Choosing between the two today, TS6 beta is a fast, lightweight deploy if voice chat with basic channel structure is genuinely all you need, and the WebQuery API is a solid foundation for automation. If you also want persistent text channels, threads, reactions, file sharing beyond avatars, and a client people do not need a separate app for, compare a self-hosted Discord alternative before you commit.