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.
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 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.
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.
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 ... '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'
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.
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.
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:
serveredit/instanceedit. Build it as a small polling script instead (below).channel_needed_talk_power above what a normal user has (e.g. 1000, vs. a default
around 0-100) β nobody in that channel can transmit, full stop. This 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, 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.
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).
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.
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.
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.