← Back to Wiki
Self-Hosting / Chat & Voice

Self-Hosting TeamSpeak 6 (Beta) in Docker

TeamSpeak 6's Docker image is a clean install, but the parts nobody documents well β€” bootstrapping API access without a chicken-and-egg deadlock, building AFK auto-move/mute yourself since the server won't do it natively, and exposing voice and file transfer publicly without silently breaking one of them β€” are where the real time goes.

Share on X

Why TS6 over classic TS3

A native TS3 install (systemd + ts3server_startscript.sh) works, but has one real annoyance worth knowing about even if you stick with TS3: the server phones home to TeamSpeak's accounting service roughly hourly, and a failed check-in triggers a clean self-shutdown β€” exit code 0. Since the exit is clean, systemd's default Restart=on-failure doesn't catch it; the server silently goes down and stays down until someone notices. If you're staying on TS3, set Restart=always in the unit file to close that gap. TS6 (beta, but stable in practice) doesn't have this specific issue and is the newer codebase, so it's the 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), and host networking sidesteps having to track 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/scripting access at all. Leave raw/telnet query (port 10011) disabled; SSH-query covers the same capability more safely.

Grab the ServerAdmin credentials from the startup log, immediately. They're printed once, on first boot: docker logs teamspeak6 shows loginname="serveradmin" and a generated password. Newer images are also supposed to print an apikey= line to skip the bootstrap step below β€” in practice that line wasn't present in our own startup log (may be version-dependent), so check first before assuming you need to mint one manually.

Bootstrapping API access: the chicken-and-egg problem

WebQuery is the convenient HTTP API for building out channels, permissions, and bots β€” but WebQuery itself requires an API key to do anything, including generate a new one. You have to bootstrap through SSH-query first:

ssh -p 10022 -o StrictHostKeyChecking=no [email protected] <<EOF
apikeyadd scope=manage lifetime=0
quit
EOF
SSH-query is a stateful interactive session, not one-shot exec. Commands have to be sent 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 directly in the URL path (/1/<command>). And the query interface's IP allowlist defaults to 127.0.0.1/ ::1 only β€” run this from inside the container/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'
File-transfer commands (ft*) are entirely excluded from WebQuery's scope model β€” undocumented. Every ft* call over WebQuery, even with a manage-scope key, returns {"code":5120,"message":"out of scope"}. This is almost certainly by design: WebQuery is stateless per-request HTTP, and ftinitupload hands off to a stateful raw TCP socket, which doesn't fit that 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 if the existing one gets deleted expecting a self-heal β€” it just 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. If you land here, the real recovery path 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. From there, log in via SSH-query specifically; WebQuery's own docs explicitly list login/logout as unsupported over HTTP, so you can't skip straight to WebQuery from a cold start.

Save any query credential somewhere durable the moment you mint it. A ServerQuery/WebQuery credential generated once, used, and then lost with no supported lookup path is an easy way to end up back in the recovery flow above for no reason. A password manager, not a scratch note, is the right place for it.

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

A common ask β€” new connections land in an AFK holding channel, idle users get moved there automatically, and both speaking and listening get disabled for anyone inside it. What's actually 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, and skip ServerQuery connections themselves (client_type=1) so the bot doesn't 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 (avatars, channel icons, chat uploads).

A NAT-traversal tunnel that remaps ports will break 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 number directly against whatever hostname it connected through. Any tunnel provider that remaps external↔internal ports (most consumer tunnel services do, as an architectural property of how they multiplex connections β€” not a config option you can turn off) leaves the client trying to connect to a port nothing is actually listening on externally. Voice keeps working the whole time, which makes this an easy problem to miss until someone tries to upload an avatar or a channel icon and it just hangs.

The fix is a tunnel approach 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, no remapping in either direction. Verify file transfer specifically after switching β€” don't assume "voice works" means the tunnel is fully correct. A real image/icon upload attempt, watched live via docker logs -f on the server side, is the actual test: if the tunnel is still remapping ports, you'll see zero server-side log activity for the failed attempt, which is the tell that the connection never reached the container at all β€” not a permissions or size-limit issue, a NAT-traversal one.

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

If channel or server icon assignment fails with invalid parameter no matter what you try β€” WebQuery, SSH-query, the real GUI client (which reports a different, misleading "insufficient permissions" error for the same underlying failure), signed vs. unsigned icon-ID representation, even setting the field to its own current, unchanged value as a total no-op β€” that's a real bug in this specific TS6 beta build, not a local misconfiguration. The conclusive test is the no-op write: if setting a property to the value it already has still fails, permissions and data validity are both ruled out, and what's left is the server's own handling of that field. TeamSpeak Server is closed-source, so this isn't something you can patch locally β€” it needs an upstream fix. Confirm you've ruled out the usual suspects (permission group has the relevant flag, file size under the server's configured limit, field name matches what the server itself reports back) before concluding it's upstream, but once you have, the right move is filing it and moving on, not spending more time on your own config.

Eventually moving on

This setup ran well for voice for months before we replaced it with a self-hosted Discord-alternative (Outpost) that covers voice and text in one place with a modern web/desktop/mobile client. If you're 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 don't need a separate app for, a self-hosted Discord-alternative is worth comparing against before you commit.