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.
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 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.
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.
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
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'
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.
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.
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:
serveredit or instanceedit. Build it as a small polling script instead, per
below.channel_needed_talk_power above what a normal user has. 1000, against a default
around 0 to 100. Nobody in that channel can transmit. Full stop. That is a proper server-enforced mute, not
a per-user toggle someone can undo.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.
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.
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.
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.
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.