Table of Contents
- teamspeak-server
- Prerequisites
- Installation
- Configurable values
- Operating notes
- Why the probes name file-transfer
- What a Ready pod does not prove
- server.voicePort only takes effect on a brand-new volume
- The first start prints two secrets and never prints them again
- No secrets, and no query interface to use them on
- The bundled license expires, and an expired one refuses to start
- More than one replica will not work
- The root filesystem is read-only and there is no scratch volume
- env is appended, and can shadow the chart's ports
- If the server refuses to start or to register, widen the egress rule first
- The protocol matters as much as the number
- A coincidence worth not copying
- The policy is only a boundary where the CNI enforces one
- Upgrades and backup
- CI
- See also
teamspeak-server
Deploys a TeamSpeak 6 server as a single-replica StatefulSet with one data volume. TeamSpeak is not HTTP, so there is no Ingress and no certificate: the chart exposes two NodePorts, one UDP for voice and one TCP for file transfer. This chart also keeps its values at the top level rather than under a chart-named key.
| Image | teamspeaksystems/teamspeak6-server |
| Versions | not listed on purpose — read the current pair off teamspeak-server/Chart.yaml, see Chart index |
| Workloads | StatefulSet (1 replica) |
| Storage | one RWO PVC on /var/tsserver — no scratch volume, no emptyDir |
| Ingress | no — NodePort Service on both ports |
| External dependencies | none (the server keeps its own SQLite database on the volume) |
| Secrets | none — the server prints its own credentials once, at first start |
| Install-test | ci/teamspeak-server/ (test values only, no fixtures) |
Prerequisites
- A StorageClass for
storage.storageClass(defaultlonghorn) that honoursfsGroup. On one that does not, the pod starts and the server then cannot open its database — that failure is in the pod log, not in the events. - Two free node ports inside the cluster's node-port range (a default cluster allocates 30000–32767). Both values are used as NodePort, so a number outside that range is rejected by the API server, not by the chart.
- Reachability of the node IPs for your users, and firewall rules for both ports — UDP for voice, TCP for file transfer.
- Decide
server.voicePortbefore the first install; it cannot be changed afterwards without a new volume (see below).
Installation
The registry URL and its credentials live in 1Password (item jlab-registry-access: username, password, charts-url) and never in a repository or a shell history file.
helm repo add jlab "$CHARTS_URL" --username "$REG_USER" --password "$REG_PASS"
helm repo update
helm install ts jlab/teamspeak-server \
--namespace teamspeak --create-namespace \
-f my-values.yaml
Minimal my-values.yaml — everything has a default, so this is optional:
storage:
size: 25Gi
storageClass: longhorn
server:
voicePort: 30987
fileTransferPort: 30033
helm install prints the chart's NOTES.txt, which renders the same information against the values actually set — and warns if a port falls outside the node-port range. Read it — it is the per-release version of this page.
How it is reached
The Service <release>-teamspeak-server-service is of type NodePort, so both ports are open on every node of the cluster and reachable by anyone who can reach a node.
voice node IP : 30987/UDP (server.voicePort)
file transfer node IP : 30033/TCP (server.fileTransferPort)
kubectl get nodes -o wide # the IPs to hand to your users
Because the voice port is not 9987 — the port a client assumes when it is given a bare host name — tell users the full address NODE-IP:30987. Inside the cluster the same ports apply; there is no separate service port:
<release>-teamspeak-server-service.<namespace>.svc.cluster.local:30987
Configurable values
Global
| Key | Type | Default | Description |
|---|---|---|---|
scaleDown |
bool | false |
Renders the StatefulSet with replicas: 0 without uninstalling. Takes precedence over replicas. PVC and Service stay, so no data is lost and the node ports stay reserved. |
replicas |
int | 1 |
Replica count; 0 is allowed. Top-level, not under a chart key. See One replica, one volume. |
global |
object | unset | Helm's conventional cross-chart values. Unread by this chart; kept open in the schema so an umbrella install cannot break on it. |
server
| Key | Type | Default | Description |
|---|---|---|---|
server.voicePort |
int (1–65535) | 30987 |
UDP voice port. Used three times: container port, Service port and NodePort, and passed to the server as TSSERVER_DEFAULT_PORT. Fixed for the life of the data volume — see below. |
server.fileTransferPort |
int (1–65535) | 30033 |
TCP file-transfer port, used the same three times plus TSSERVER_FILE_TRANSFER_PORT. Unlike the voice port, this one is applied on every start and really does follow the value. It is also the port every probe addresses, by the name file-transfer. |
The schema accepts 1–65535 because that is what a port is; a default cluster only allocates 30000–32767. A value outside the range renders fine and is rejected by the API server when the Service is applied, with provided port is not in the valid range and no mention of this chart. NOTES.txt names the offending value explicitly for that reason.
storage
| Key | Type | Default | Description |
|---|---|---|---|
storage.size |
string | 25Gi |
Size of the data PVC. Growing works only on a StorageClass that allows expansion; shrinking is refused by Kubernetes and fails the next upgrade on the PVC instead of touching data. |
storage.storageClass |
string | longhorn |
StorageClass of the data PVC. Must honour fsGroup. |
Deliberately not null-safe: an erased storage block yields an invalid PVC rather than a silently degraded workload.
Probes, hardening, resources and env (all top-level)
| Key | Type | Default | Description |
|---|---|---|---|
livenessProbe |
object | tcpSocket on port file-transfer, period 10s, timeout 5s, 3 failures |
The voice service is UDP and cannot be probed, so every probe connects to the TCP file-transfer port instead — addressed by name. |
readinessProbe |
object | tcpSocket on port file-transfer, period 10s, timeout 5s, 3 failures |
|
startupProbe |
object | tcpSocket on port file-transfer, period 5s, timeout 5s, 30 failures (= 150s) |
Gates liveness and readiness. |
securityContext.pod |
object | non-root 9987:9987, fsGroup: 9987, fsGroupChangePolicy: Always |
Passed verbatim into the pod spec; 9987 is the image's teamspeak user. fsGroup is what makes the data volume writable. |
securityContext.container |
object | allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, runAsNonRoot: true, capabilities.drop: [ALL], capabilities.add: [] |
Passed verbatim into the container spec. |
env |
list | [] |
Extra environment variables for the server container. Rendered through tpl; value, secretKeyRef and configMapKeyRef forms. Appended, never merged — see the port trap. |
resources.limitFactor |
number | 3 |
Memory and ephemeral-storage limits are computed as factor × request. |
resources.requests |
object | cpu: 0.2, memory: 100Mi, ephemeral-storage: 100Mi |
|
resources.limits |
object | unset (computed: memory: 300Mi, ephemeral-storage: 300Mi) |
An explicit limit always wins. A CPU limit is only rendered when set here. Since the container writes nothing outside the volume, the ephemeral-storage limit is headroom rather than a working budget. |
An erased block (securityContext: with no children, a null probe, a null resources) falls back to the chart's own defaults instead of disappearing — style guide §12.2.
networkPolicy
Three ports, three treatments:
- voice (
server.voicePort, UDP): NodePort, open — the clients are on the internet. - file transfer (
server.fileTransferPort, TCP): NodePort, open, and also the port all three probes open a socket against. - web query (10080): restricted to this namespace plus
allowedNamespaces. No Service, no probe, and administrative — this is the rule with real value here.
Outbound: DNS and TCP 443. There is no probeSources value: the probed port is open for an independent reason (it is a NodePort facing the internet), so the value would reach no rule.
| Value | Type | Default | Meaning |
|---|---|---|---|
networkPolicy.enabled |
bool | true |
Renders the policy. Only a boundary where the CNI enforces one — unenforced, the API server accepts it and nothing is filtered, so everything reads as configured. |
networkPolicy.allowedNamespaces |
list | [] |
Namespaces whose pods may reach the restricted ports, additionally to the release's own. Matched on the kubernetes.io/metadata.name label Kubernetes sets itself — no labelling work, and no typo protection either: a name that does not exist matches nothing and is silently ineffective. Namespace-granular, so an entry opens the port to every pod in it. |
networkPolicy.ingressController |
object | kube-system / app.kubernetes.io/name: rke2-traefik |
The controller allowed to reach the routed port once probeSources restricts it. The two keys render as one from entry, i.e. an AND — which is why this is not a line in allowedNamespaces, where it would open the port to every pod in kube-system. Default measured on the cluster, not assumed. |
networkPolicy.egress.extraPorts |
list | [] |
Additional outbound TCP ports, to any destination. The escape hatch from the egress cap, and the only alternative to switching the policy off. |
Operating notes
Why the probes name file-transfer
This chart is the repo's cautionary example for style guide §12.5: a probe default carries the probe's context, not just its timings. The voice service is UDP and cannot be probed, so all three probes are TCP connects to the file-transfer port, addressed by the port name file-transfer. A rebuilt fallback default that keeps only the timings and drops the name leaves the workload with no working probe at all — and nothing says so.
What a Ready pod does not prove
Green means exactly one thing: the file-transfer listener accepts a connection. It says nothing about voice, which is the actual service. A release can be fully Ready while the voice port is wrong, while the virtual server is stopped, or while every slot is taken. The license is not covered either — though an expired one crashes the process and does surface as a restart loop.
The real check is a client connecting to NODE-IP:<voicePort>. Short of that, the server's own view:
kubectl -n <namespace> logs statefulset/<release>-teamspeak-server-sfs | grep "listening on"
which prints the ports the server actually bound — compare them with the two configured ones.
server.voicePort only takes effect on a brand-new volume
The trap of this chart, and it is silent in both directions. The chart passes server.voicePort as TSSERVER_DEFAULT_PORT, and the server reads that variable only when it creates the virtual server; from then on the port lives in the virtual server's row in /var/tsserver/tsserver.sqlitedb and the variable is ignored. Measured on 6.0.0-beta12.1: a restart against an existing volume with a changed TSSERVER_DEFAULT_PORT kept listening on the old port.
Changing it on a release that already has data therefore moves the Service port, the NodePort and the containerPort to the new number while the server keeps answering on the old one. Voice becomes unreachable — and every probe stays green, because they watch the other port.
There is no comfortable way out: the port is a property of the virtual server in the database, and the only interface that can edit it is ServerQuery, which this chart does not expose. Treat server.voicePort as fixed for the life of the data volume; if it really has to change, plan for a new volume and a new server.
The first start prints two secrets and never prints them again
Against an empty data volume the server creates its database, a virtual server, a ServerQuery admin account and a ServerAdmin privilege key. All of it goes to stdout once, at that first start only, and into no Secret:
kubectl -n <namespace> logs <release>-teamspeak-server-sfs-0
Two different things are printed, and they are not equally recoverable:
| Printed | What it is | Recoverable? |
|---|---|---|
token=… |
the ServerAdmin privilege key — paste it into a connected client to become server admin | yes: also written to the log files on the data volume, grep -h "token=" /var/tsserver/logs/*.log |
serveradmin / apikey |
the ServerQuery credentials | no: stdout only, never in the log files. Once the pod's stdout is gone, they are gone |
Losing the privilege key without having claimed admin rights means starting over: the identity that holds admin is stored in the database, so the way back is deleting the volume and letting a new server be created. A second start against an existing volume prints neither — that includes every ordinary restart, rollout, node drain or app-version bump. If a fresh install seems to be missing its token, check whether the PVC already existed.
No secrets, and no query interface to use them on
This chart creates no OnePasswordItem and no Kubernetes Secret — there is no secrets.* value and nothing to prepare in a vault. Which matters, because those ServerQuery credentials have nowhere to go: measured on 6.0.0-beta12.1, the only listening sockets in the container are UDP (voice) and TCP (file transfer). The pod declares a third container port, web-query 10080, and nothing binds it; it is also absent from the Service. The server additionally restricts query access to 127.0.0.1 and ::1 by default. Administration therefore happens through a connected TeamSpeak client with the privilege key, not over ServerQuery.
The bundled license expires, and an expired one refuses to start
The image ships without a license file and falls back to the built-in one. Measured by running 6.0.0-beta12.1:
type : No License
starting date : Wed Jul 1 00:00:00 2026
ending date : Thu Oct 1 00:00:00 2026
max virtualservers: 1
max slots : 32
Two consequences. 32 slots is the real capacity of this release, whatever the resources say. And after the ending date the server refuses to start — a working deployment turns into CrashLoopBackOff with no change on your side. The fix is an app-version bump to a newer beta, which ships a license with a later date; no values change can solve it. Confirm the dates for the version you actually run:
kubectl -n <namespace> logs <release>-teamspeak-server-sfs-0 | grep -A6 "Licensing Information"
More than one replica will not work
Every replica mounts the same ReadWriteOnce claim <release>-teamspeak-volume-claim — a plain PVC, not a volumeClaimTemplate, so there is no per-pod volume. Where the extra pods do start, two servers open the same SQLite database and corrupt it, and the Service sprays a client's UDP packets across both pods, which no TeamSpeak session survives. This chart is built for one replica.
Note the claim name: <release>-teamspeak-volume-claim, without the chart name. That is a historical name the chart keeps deliberately — renaming it would orphan the data (style guide §1).
The root filesystem is read-only and there is no scratch volume
The container runs non-root as 9987:9987 with readOnlyRootFilesystem and all capabilities dropped, and /var/tsserver is the only writable path — the chart mounts no emptyDir. Verified against 6.0.0-beta12.1: the server needs nothing else, so this is the supported configuration and not a deviation to "fix". An image that later wants a writable /tmp would fail at start, not at build time.
env is appended, and can shadow the chart's ports
Entries land after the three variables the chart always sets — TSSERVER_LICENSE_ACCEPTED, TSSERVER_DEFAULT_PORT, TSSERVER_FILE_TRANSFER_PORT. Kubernetes keeps the last value for a repeated name, so redefining either port variable here wins inside the container while the Service, the NodePort and the containerPort still follow server.*. That splits the two apart with nothing reporting it — set the ports under server, never through env. Values are rendered through tpl, so anything sensitive belongs in valueFrom.secretKeyRef: a plain value is stored in the pod spec and readable by everyone who can read pods in the namespace.
If the server refuses to start or to register, widen the egress rule first
TeamSpeak's accounting service has historically used ports of its own — TCP 2008 and UDP 2010 on the 3.x line — and this chart cannot know what the 6.x beta does. Put them in networkPolicy.egress.extraPorts.
The install-test covers this chart, so a startup that depends on a blocked port shows up there rather than in production. A check that merely degrades would not.
The protocol matters as much as the number
Voice is UDP. A rule written for TCP on the same number would be accepted, would look complete, and would drop every voice packet — which is why the render test checks the protocol against the containerPort's and not just the number.
A coincidence worth not copying
This chart happens to use the same number for the pod port and the NodePort, so the usual NodePort mistake is invisible here: the number is right either way. That is a property of these values, not a rule — do not carry the pattern to a chart where the two differ.
The policy is only a boundary where the CNI enforces one
The API server accepts a NetworkPolicy whatever the cluster does with it. Where the CNI does not enforce policies, everything looks configured and nothing is filtered — which is worse than no policy, because it invites the assumption that something is being filtered. Verify by connecting from an unrelated namespace rather than by reading it off the manifest.
Measured for this cluster: the CNI is Canal (Flannel for the network, Calico for policy), 6/6, and it does enforce. Measured for CI: k3s brings its own policy controller and enforces too — but it exempts the kubelet's probe traffic, so a blocked probe path does not turn the install-test red. What CI does catch is anything the application itself needs.
Upgrades and backup
- The data volume is the server. Everything lives in
/var/tsserver:tsserver.sqlitedb(the virtual server, its port, all permissions, identities, channels and ServerQuery accounts),files/(file-transfer uploads),logs/(including the privilege key) andquery_ip_*list.txt. - It is an ordinary PVC of this release and carries no resource-policy annotation.
helm uninstall <release>deletes it, and with aDeletereclaim policy the whole server goes with it — not just chat history, but the admin identity and the privilege key. Back it up before uninstalling; an annotation added to the live PVC afterwards does not help, because Helm reads it from the release manifest. - App-version bumps are the license mechanism, not just a feature update: they are what carries a later expiry date. Renovate tracks
teamspeaksystems/teamspeak6-serverwithversioning: loose, because the 6.0.0 line ships only beta tags and loose compares the numeric parts left to right, sobeta12.1sorts abovebeta9where semver would rank every beta as an equal prerelease — see Renovate. - A restart never re-prints the first-start credentials; make sure they are stored somewhere before the pod is replaced.
CI
ci/teamspeak-server/test-values.yaml sets only storage.size: 1Gi and storage.storageClass: local-path — the chart needs no secrets and no throwaway infrastructure, so there is no fixtures.yaml. The install-test waits for the StatefulSet to roll out, which here means the file-transfer listener accepted a connection; the caveats above apply to that signal as much as to a production one. See CI/CD.
See also
- Chart style guide — §12.5, whose cautionary example this chart is
- Versioning — what a bump of
5.1.0+up6.0.0.beta12.1means, and why the+upsuffix normalizes the dash - Lessons learned
- Chart index
helm-charts wiki
How we work
How it is built
How charts are written
Charts
- apache-tika
- bentopdf
- bulwark
- calibre-web-automated
- convertx
- cyberchef
- freesailarr
- garage
- gotenberg
- homeassistant
- keycloak
- matrix-synapse
- minecraft-router
- minecraft-server
- outline
- paperless-ngx
- patchmon
- rallly
- rss-bridge
- samba
- satisfactory
- searxng
- stalwart
- sure
- teamspeak-server
- template-chart
- urlaubsverwaltung
- valkey
- voucher-vault
- ytdlp-web-player
- zipline