{{- $replicas := include "teamspeak-server.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.replicas) -}} {{- $res := fromYaml (include "teamspeak-server.effectiveResources" .Values.resources) -}} {{- $eph := index ($res.limits | default dict) "ephemeral-storage" | default "the computed ephemeral-storage limit" -}} {{- $startup := fromYaml (include "teamspeak-server.startupProbe" .Values.startupProbe) -}} {{- $budget := "" -}} {{- if and (kindIs "float64" $startup.periodSeconds) (kindIs "float64" $startup.failureThreshold) -}} {{- $budget = printf "%.0f seconds" (mulf $startup.periodSeconds $startup.failureThreshold) -}} {{- end -}} {{- $svc := printf "%s-%s-service" .Release.Name .Chart.Name -}} {{- $sfs := printf "%s-%s-sfs" .Release.Name .Chart.Name -}} {{- $pod := printf "%s-%s-sfs-0" .Release.Name .Chart.Name -}} {{- $pvc := printf "%s-teamspeak-volume-claim" .Release.Name -}} {{- $voice := .Values.server.voicePort -}} {{- $ft := .Values.server.fileTransferPort -}} {{- $badPorts := list -}} {{- if or (lt (int $voice) 30000) (gt (int $voice) 32767) }}{{- $badPorts = append $badPorts (printf "server.voicePort (%v)" $voice) }}{{- end }} {{- if or (lt (int $ft) 30000) (gt (int $ft) 32767) }}{{- $badPorts = append $badPorts (printf "server.fileTransferPort (%v)" $ft) }}{{- end }} TeamSpeak {{ .Chart.AppVersion }} — release {{ .Release.Name }} in namespace {{ .Release.Namespace }}. ACCESS — TWO NODE PORTS, NO INGRESS TeamSpeak is not HTTP, so this chart renders no Ingress and no certificate. The Service {{ $svc }} 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 : {{ $voice }}/UDP (server.voicePort) file transfer node IP : {{ $ft }}/TCP (server.fileTransferPort) kubectl get nodes -o wide # the IPs to hand to your users Each of the two numbers is used THREE times: as the container port, as the Service port and as the nodePort. That is deliberate — TeamSpeak's file transfer announces its own port to the client, so the port inside the container has to be the same one the client reaches from outside. It also means the voice port cannot be 9987, the port a client assumes when it is given a bare host name. Tell your users the full address: NODE-IP:{{ $voice }} {{- if $badPorts }} OUT OF THE NODE-PORT RANGE: {{ join ", " $badPorts }} A default cluster only allocates node ports from 30000-32767. The values schema accepts 1-65535, so this rendered fine — the API server is what rejects it, when you apply the Service, with "provided port is not in the valid range". Nothing about that error mentions this chart. Either pick a port inside the range or widen --service-node-port-range on the API server. {{- end }} Inside the cluster the same ports apply; there is no separate service port: {{ $svc }}.{{ .Release.Namespace }}.svc.cluster.local:{{ $voice }} THE FIRST START PRINTS TWO SECRETS AND NEVER PRINTS THEM AGAIN Against an empty data volume the server creates its database, and with it 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 {{ .Release.Namespace }} logs {{ $pod }} Two different things are printed, and they are not equally recoverable: token=... the ServerAdmin privilege key. This is what you paste into a connected client to become server admin. Also written to the log FILES on the data volume, so it survives a lost stdout: kubectl -n {{ .Release.Namespace }} exec {{ $pod }} -- \ sh -c 'grep -h "token=" /var/tsserver/logs/*.log' serveradmin / apikey the ServerQuery credentials. These go to stdout ONLY — they are not in the log files on the volume. 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 — a rollout, a node drain, an app-version bump. If a fresh install seems to be missing its token, check whether the PVC below already existed. server.voicePort ONLY TAKES EFFECT ON A BRAND-NEW VOLUME This is the trap of this chart, and it is silent in both directions. The chart passes server.voicePort to the container as TSSERVER_DEFAULT_PORT. 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 {{ .Chart.AppVersion }}: a restart against an existing volume with a changed TSSERVER_DEFAULT_PORT kept listening on the old port. So changing server.voicePort on a release that already has data moves the Service port, the nodePort and the containerPort to the new number, while the server inside the pod keeps answering on the old one. Voice becomes unreachable, and — see below — every probe stays green. There is no comfortable way out of that. 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 (see below). Treat server.voicePort as fixed for the life of the data volume: pick it before the first install, and if it really has to change, plan for a new volume and a new server. server.fileTransferPort behaves the opposite way: TSSERVER_FILE_TRANSFER_PORT is applied on every start, so that one really does follow the value. {{- if eq $replicas "0" }} SCALED TO ZERO {{ if .Values.scaleDown }}scaleDown: true{{ else }}replicas: 0{{ end }} — the StatefulSet runs with replicas: 0, so nothing answers on the ports above until you set it back and upgrade again. The data PVC and the Service stay in place, so no data is lost and the node ports stay reserved. {{- end }} {{- if gt (atoi $replicas) 1 }} MORE THAN ONE REPLICA WILL NOT WORK replicas is {{ $replicas }}. Every replica mounts the same ReadWriteOnce claim {{ $pvc }} — this chart writes a plain PVC, not a StatefulSet volumeClaimTemplate, so there is no per-pod volume. Unless the storage class hands that claim to several nodes at once, the extra pods never start; where they do, two servers open the same SQLite database and corrupt it. The Service would also spray a client's UDP packets across both pods, which no TeamSpeak session survives. This chart is built for one replica. {{- end }} THE BUNDLED LICENSE EXPIRES ON 2026-10-01 This image ships without a license file and falls back to the built-in one. Measured by running {{ .Chart.AppVersion }}: 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. The 32-slot ceiling 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; that is tracked separately and is not something a values change can solve. Confirm the dates for the version you actually run: kubectl -n {{ .Release.Namespace }} logs {{ $pod }} | grep -A6 "Licensing Information" NO SECRETS, AND NO QUERY INTERFACE TO USE THEM ON This chart creates no OnePasswordItem and no Kubernetes Secret — there is no secrets.* value to set, and nothing to prepare in a vault before installing. The server's own credentials are the ones printed at first start. Which matters, because those ServerQuery credentials have nowhere to go. On a default install nothing listens on a query port: measured on {{ .Chart.AppVersion }}, the only listening sockets in the container are UDP {{ $voice }} (voice) and TCP {{ $ft }} (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. WHAT A READY POD DOES NOT PROVE All three probes are bare TCP connects to the file-transfer port. Green means one thing: the file-transfer listener accepts a connection.{{ if $budget }} The startup probe covers {{ $budget }} and keeps liveness and readiness off until then.{{ end }} It says nothing about voice, which is the actual service — UDP cannot be probed this way, so no probe in this chart ever touches it. A release can be fully Ready while the voice port is wrong (see server.voicePort above), while the virtual server is stopped, or while every slot is taken. It says nothing about the license 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:{{ $voice }}. Short of that, the server's own view: kubectl -n {{ .Release.Namespace }} logs {{ $sfs }} | grep "listening on" which prints the ports the server actually bound — compare them with the two at the top of these notes. They should match; when they do not, the cause is almost always the port trap above. THE DATA VOLUME IS THE SERVER, AND IT BELONGS TO THIS RELEASE Everything is in one directory, /var/tsserver, on the claim {{ $pvc }} ({{ .Values.storage.size }}{{ if .Values.storage.storageClass }} from storageClass {{ .Values.storage.storageClass }}{{ end }}): tsserver.sqlitedb the virtual server, its port, all permissions, identities, channels and ServerQuery accounts files/ everything uploaded through file transfer logs/ the server's own logs, incl. the privilege key query_ip_*list.txt the query allow/deny lists Note the claim name: {{ .Release.Name }}-teamspeak-volume-claim, without the chart name. That is a historical name this chart keeps deliberately — renaming it would orphan the data. It is an ordinary PVC of this release, not a volumeClaimTemplate, and it carries no resource-policy annotation. "helm uninstall {{ .Release.Name }}" deletes it, and with a Delete reclaim 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 that annotation from the release manifest. Growing storage.size only works if the storage class allows volume expansion. Shrinking is refused by Kubernetes and makes the next upgrade fail on the PVC instead of touching the data. THE ROOT FILESYSTEM IS READ-ONLY AND THERE IS NO SCRATCH VOLUME The container runs non-root as uid/gid 9987 with readOnlyRootFilesystem and all capabilities dropped, and /var/tsserver is the only writable path — the chart mounts no emptyDir. Verified against {{ .Chart.AppVersion }}: 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. fsGroup 9987 is what makes the volume writable. On a storage class that does not honour fsGroup the pod starts and the server then cannot open its database — that failure is in the pod log, not in the events. {{- if .Values.storage.storageClass }} This release uses "{{ .Values.storage.storageClass }}".{{ end }} Whatever the container writes outside the volume is nothing, so the ephemeral-storage limit ({{ $eph }}) is headroom rather than a working budget. {{- if .Values.env }} env IS APPENDED, NOT MERGED — AND CAN SHADOW THE CHART'S PORTS The {{ len .Values.env }} entr{{ if eq (len .Values.env) 1 }}y{{ else }}ies{{ end }} here land{{ if eq (len .Values.env) 1 }}s{{ end }} AFTER the three variables the chart always sets: TSSERVER_LICENSE_ACCEPTED, TSSERVER_DEFAULT_PORT and 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 they may contain template expressions. Anything sensitive belongs in valueFrom.secretKeyRef: a plain "value" is stored in the pod spec and readable by everyone who can read pods in {{ .Release.Namespace }}. {{- end }} {{- if .Values.networkPolicy.enabled }} NETWORKPOLICY IS ACTIVE — WHAT IT CAPS, AND WHAT IT DOES NOT {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy selects this workload's pods. Inbound: voice (UDP {{ .Values.server.voicePort }}) and file transfer (TCP {{ .Values.server.fileTransferPort }}), both NodePorts and both open — the clients are on the internet; and the web query interface (TCP 10080), RESTRICTED to this namespace plus networkPolicy.allowedNamespaces, because it has no Service, no probe, and is administrative. Outbound: DNS and TCP 443{{ with .Values.networkPolicy.egress }}{{ with .extraPorts }}, plus TCP {{ join ", " . }}{{ end }}{{ end }}. IF THE SERVER WILL NOT START OR WILL NOT REGISTER AFTER THIS UPGRADE, widen the egress rule: TeamSpeak's accounting service has historically used ports of its own (TCP 2008, UDP 2010 on the 3.x line), and this chart cannot know what the 6.x beta does. Put them in networkPolicy.egress.extraPorts. Three limits belong in the same breath, because none of them is visible in the object itself: - EGRESS IS BY PORT, NEVER BY DESTINATION. A NetworkPolicy matches CIDRs and selectors and cannot express a hostname, so every egress rule permits its port to ANY address. That is a limit of the object, not a shortcut — this cluster's CNI (Canal: Flannel plus Calico) has no FQDN rule either. An outbound port the chart does not know about is BLOCKED: add it to networkPolicy.egress.extraPorts, and expect to find out through an application-level connection error rather than through this object. {{- if .Values.networkPolicy.probeSources }} - THE PROBE PATH IS RESTRICTED in this release, because networkPolicy.probeSources is set to {{ join ", " .Values.networkPolicy.probeSources }} Probes come from the IP of the WORKER NODE running the pod. If none of those CIDRs covers the node this pod lands on, the kubelet's probes are dropped and THE POD NEVER BECOMES READY — the rollout then times out and the symptom looks like a broken application. Compare kubectl -n {{ .Release.Namespace }} describe pod -l app={{ .Release.Name }}-{{ .Chart.Name }}-sfs against `kubectl get node -o wide`. {{- else }} - THE PROBED PORT IS OPEN INSIDE THE CLUSTER. networkPolicy.probeSources is empty, so that rule carries no source selector at all and any pod the CNI routes to can reach it. Not from the internet — but this is NOT pod-level isolation. Narrowing it means listing the worker node IPs in networkPolicy.probeSources, because the kubelet probes from the node's own address and the chart cannot know it; a wrong guess there leaves the pod permanently un-Ready. {{- end }} - IT IS ONLY A BOUNDARY WHERE THE CNI ENFORCES POLICIES. Where it does not, this object is accepted and filters nothing, so everything reads as configured while nothing is filtered. `kubectl -n {{ .Release.Namespace }} get networkpolicy` shows the object exists; only a connection attempt from an unrelated namespace shows that it bites. Switch it off with networkPolicy.enabled: false. {{- end }}