TeamSpeak hosting
Two TeamSpeak 3 servers on one Debian VM, how they are deployed, administered and updated, the host's weekly vulnerability-gated update window, and the collector that tells a server fault from a public-path fault.
I host two TeamSpeak 3 voice servers. They run on alpha-prod-01, a Debian 13 VM, beside a web admin tool and a reachability collector I wrote. Voice is the only part reachable from the internet; administration happens on the LAN.
The host
| Item | Value |
|---|---|
| Guest | VM 401 alpha-prod-01 on purple-server, NVMe-backed local-lvm |
| Resources | 6 vCPU with cpu: host, 4 GiB maximum memory with a 2 GiB balloon minimum, 60 GiB system disk |
| OS | Debian 13 |
| Network | VLAN 80, SERVERS-A |
| Containers | ts-valorant-02, ts-valorant-03, ts3-manager, teamspeak-monitor, the publishing service, the Dockhand agent (Hawser 0.2.49) and cAdvisor |
On 2026-09-12 I moved the VM from grey-server to purple-server. grey-server is AMD and purple-server is Intel, and the guest exposes its host CPU type, so it had to be an offline move. The first attempt was cut off by a ten-second command timeout in my tooling and Proxmox aborted it cleanly; I removed the partial destination volume and reran the migration under systemd-run so it no longer depended on the SSH session. The 60 GiB disk copied at about 116 MB/s and the task finished in 9 minutes 17 seconds. The first collector cycle after boot overlapped boot recovery and failed; the next one, at 8:21:29 PM Eastern, showed every probe up.
The two servers
| Server | Virtual server name | Compose project | Data volume |
|---|---|---|---|
ts-valorant-02 | AlphaSec United x HomeBase | teamspeak-02 | teamspeak-02_ts-data |
ts-valorant-03 | AlphaSec United x Valorant 03 | teamspeak-03 | teamspeak-03_ts-data |
Both run TeamSpeak 3 Server 3.13.8 from the official teamspeak image (read 2026-09-25; the image was built on 2026-09-17 and both containers were recreated at 12:17 PM on 2026-09-18). Each server is its own Compose project with its own named volume, so recreating one project never touches the other server’s data. The Compose file is short:
services:
teamspeak:
image: teamspeak
container_name: ts-valorant-02
restart: unless-stopped
network_mode: host
environment:
TS3SERVER_LICENSE: accept
TS3SERVER_DEFAULT_VOICE_PORT: <voice port>
TS3SERVER_QUERY_PORT: <query port>
TS3SERVER_FILETRANSFER_PORT: <file transfer port>
volumes:
- ts-data:/var/ts3server
volumes:
ts-data:
Both containers use host networking, so every server needs its own unique set of voice, ServerQuery and file-transfer ports. Only the UDP voice ports are published to the internet. ServerQuery and file transfer stay on the LAN, and each instance’s ServerQuery allowlist admits only loopback, the host itself, one admin workstation and the TS3 Manager container, plus one leftover entry on server 02 from an earlier bridge-mode deployment.
Server 01’s retirement
There used to be three servers. On 2026-08-09 I retired ts-valorant-01: I removed its container, Compose project, data volume and directory, took it out of the collector and its .env, removed its line from the boot-recovery script, deleted the stale copies of that script, and deleted its two public DNS records. An earlier deletion in TS3 Manager had removed the virtual server but not the Docker objects, which is why the host still had three containers. On 2026-08-31 I also removed the retired project from the Ansible Compose inventory so a fleet run can’t recreate it. The Grafana dashboard needed no change, because it discovers servers from Prometheus labels instead of a fixed list.
Administration
TS3 Manager (joni1802/ts3-manager) is the web admin interface. It is published over internal HTTPS through Nginx Proxy Manager and talks to both servers over normal ServerQuery on the LAN, never through the public path and never over SSH.
Each instance keeps its own ServerQuery admin login with its own password. The server generates replacement passwords; they can’t be chosen.
Boot recovery
The agent that carries public voice traffic runs as its own Compose project, independent of both voice servers, so restarting or removing one server never tears down the public path for the other.
A boot-recovery script runs from the admin account’s crontab at reboot. It waits 90 seconds, waits for Docker and for DNS to answer, restarts the two voice projects, and then restarts the publishing service. That ordering fixed an old startup race where containers came up before the publishing service could resolve its endpoint, and it lets the voice servers download the myTeamSpeak ID revocation list on start.
How the host is updated
| Layer | How | When |
|---|---|---|
| Container images | Dockhand checks and applies updates after a vulnerability scan with both scanners, and blocks any update carrying critical or high findings | Mondays, 3:00 AM Eastern |
| Unused images | Dockhand prunes dangling images | Mondays, 4:00 AM Eastern |
| Dockhand agent | Updated on demand by starting its stopped updater helper | When I choose |
| OS packages | The Ansible os-update.yml playbook, with reboots reported rather than taken | When I run it |
| Compose projects | The Ansible Compose play covers the publishing service, both voice servers, the collector and TS3 Manager | When I run it |
alpha-prod-01 is the only Dockhand environment on a weekly schedule with a vulnerability gate; the other six update nightly with no scanner. The containers page has the full schedule.
The collector’s image is my own build, published by hand to the private Forgejo registry as homelab-images/teamspeak-monitor:stable. The Ansible Compose play keeps pull: never on it, so a new build only reaches the host when I publish it. Before the 2026-09-15 registry cutover, Dockhand’s update check failed on the collector because it was a local image with no registry to query; it carried a temporary dockhand.update: "false" label until the cutover gave it a real registry name.
The last OS package pass, on 2026-10-02, installed or upgraded 60 packages on this host, including a newer kernel. The guest kept running on 6.12.107 with 6.12.111 installed and waiting for the next reboot window.
Monitoring
The normal exporters don’t answer the question I care about. blackbox_exporter has HTTP, TCP, ICMP, DNS and gRPC probers but no UDP prober, and TeamSpeak voice is UDP, so no blackbox module can test the path a user takes. A TCP probe against a UDP relay proves nothing either.
So I wrote teamspeak-monitor, a small Python collector in an Alpine image with bind-tools. Every 60 seconds it does three things for each server:
- Looks up the server’s public address in DNS, so a change on the public side follows DNS instead of needing an edit here.
- Sends a real TeamSpeak 3
Init1handshake to that public address. - Sends the same handshake to the server’s local voice port.
A probe only passes on a reply whose MAC is TS3INIT1, so it proves the voice service answered, not just that a port was open. From the two results it derives where a fault sits, and it reads ServerQuery statistics for each server. It writes everything as a Prometheus textfile that the host’s node_exporter already publishes, so it needed no new scrape target.
| Metric | Meaning |
|---|---|
teamspeak_public_up | The public address a user types answered. The user-facing light |
teamspeak_public_rtt_seconds | Handshake round trip through the public path |
teamspeak_local_up | The voice service answered on its local port |
teamspeak_local_rtt_seconds | Local handshake round trip |
teamspeak_dns_srv_up | The public DNS lookup succeeded |
teamspeak_tunnel_fault | Local up, public down: the public path or DNS is at fault |
teamspeak_server_fault | Local down: the TeamSpeak server is at fault |
teamspeak_last_probe_timestamp_seconds | Freshness, so a stuck collector shows instead of stale green |
teamspeak_query_up, teamspeak_clients_online, teamspeak_channels_online, teamspeak_uptime_seconds, teamspeak_max_clients | ServerQuery statistics per server |
Why it runs on the TeamSpeak host
I measured before choosing. From alpha-prod-01, every public endpoint answered a handshake in 44 to 46 ms. From monitor-01, all of them timed out, because the monitoring zone’s egress policy allows only approved web and NTP traffic out. Opening a hole from that zone to the public endpoint would have undercut the rule that keeps the SIEM segment it shares from calling out, and the public endpoint’s addresses move, so a rule pinned to an address would break on the public endpoint’s schedule and blame the wrong component. Running on the TeamSpeak host needed no firewall change, and the public probe still leaves the network and comes back, so it tests the real external path.
Proving the failure paths
I forced both failure cases in throwaway containers writing to /tmp, leaving the live service alone. An unresolvable domain produced tunnel_fault 1 and server_fault 0; an unreachable local address produced server_fault 1 and tunnel_fault 0. A monitor whose failure path has never fired is a monitor that can lie.
My first version also read a single ServerQuery password for every server, which would have authenticated against one and failed silently on the others. Each server now has its own password variable, and teamspeak_query_up exists so a login failure shows as a red panel instead of a missing series.
The 17-day DNS blind spot
On 2026-08-27 the dashboard had shown both public addresses down for 17 days while both servers were serving clients. The metrics were telling the truth: teamspeak_dns_srv_up was 0 and the public-endpoint label read unresolved:0, so the public probe had never run.
The cause was a boot race. On 2026-08-10 Docker created the container two seconds before the host’s DHCP client wrote the nameserver line into /etc/resolv.conf. Docker copies that file into a container once, at creation, and never revisits it, so the container ran for 17 days with no nameserver at all. I pinned the resolver in the collector’s Compose file with a dns: entry, after confirming on Docker 29.6.2 that the option is honored under network_mode: host. A plain restart would also have fixed it, and would have broken again on the next slow boot. I rejected bind-mounting /etc/resolv.conf, because the DHCP client replaces that file rather than writing through it, and a single-file bind mount would pin the old copy.
Any container that resolves names and was created during a boot carries the same risk, so I swept the fleet the same day. I verified 52 of 60 running containers and none was affected; the other eight sat on two hosts where my account had no non-interactive Docker access. I also rebuilt the dashboard so name resolution sits in the top row beside the public and local lights, and an unresolved public endpoint renders in red as a DNS failure rather than an outage.
Dashboards
The Grafana dashboard teamspeak in the Homelab folder has rows for public availability, fault isolation and server statistics. Since 2026-09-15 a private Uptime dashboard also shows both public paths, their status history, observed uptime and monitoring coverage, and treats a collector timestamp older than three minutes as unknown.
Scripts
| Script | Purpose |
|---|---|
ts3-probe.py | The collector’s handshake check in a form I can point at anything by hand. --public probes the public names, --local the local ports; it exits 0 only when every endpoint answered |
rotate-serverquery-password.ps1 | Rotates one instance’s ServerQuery password, proves the new one authenticates, stores it in my password vault without displaying it, reads it back and confirms the old one is rejected. Kept on local disk only, because it calls the vault’s CLI by name |
ts3_export_channels.py | Exports a server’s channel tree to JSON through the TeamSpeak client’s ClientQuery plugin, so the source server’s ServerQuery doesn’t need to be reachable |
ts3_import_channels.py | Recreates that channel tree on a target server through ServerQuery, with --dry-run and --skip-existing |
Comparing the two probe paths places a fault: public failing while local passes means the public path or DNS broke, and both failing means the TeamSpeak server did. No script holds a credential, an address or a domain; every value comes from an argument or the environment.