Repository navigation
Deployment
The README's Quick Start covers a plain Docker setup. This page covers running it behind a reverse proxy and the things that catch people out there.
The sidecar's WebRTC media ports (50000-50100/udp) must stay published directly, whatever else you do.
That is UDP traffic and cannot pass through an HTTP reverse proxy. It needs its own firewall rule on the host. If you forget, everything else works perfectly and only video streaming fails — usually with no obvious error, which makes it an unpleasant one to debug.
The port range is configurable via ICE_UDP_PORT_MIN / ICE_UDP_PORT_MAX (see Configuration), but the published Docker ports and those variables must always agree. Nothing else of the sidecar is published: its HTTP API (port 9800) is an internal interface of the stack, so the compose file leaves it inside the Docker network - do not add a 9800 mapping to your own copy. After an update, docker port ts6-sidecar should list only udp lines.
Use docker-compose.coolify.yml as a starting point. The differences from the standard compose file:
- No
portssection on backend and frontend — the reverse proxy handles routing - The sidecar's UDP media ports stay published directly, as above
- Set the domain on the frontend service, port 8080. It was 80 before the containers ran unprivileged: a Coolify setup from before keeps pointing at 80 and answers 502 until that port is changed
- The file has the same one-shot
volume-initservice as the standard compose file; it is expected to end upexited. It was tried under plain Docker Compose, not inside Coolify itself. If Coolify reports the deployment as unhealthy because of it, exclude that service from the health check (Coolify's documentation describesexclude_from_hcfor a one-time service like this) - Coolify's own proxy in front of the frontend container counts for
TRUST_PROXYlike any other; Settings → Network shows the number that fits
If your TeamSpeak server runs in its own Docker network, attach the backend to it as an external network:
services:
backend:
networks:
- ts6-network
- ts-server-net
networks:
ts-server-net:
external: true
name: your-ts-server-network-idThen use the TeamSpeak container's name as the host when adding the connection. Remember that the host field is resolved from inside the backend container — see Getting Started.
The frontend is a plain static site served by nginx on port 8080 inside its container (the compose file publishes it as 3000); the backend is an HTTP API on port 3001. Proxy the frontend as your public entry point and set FRONTEND_URL on the backend to your public origin, so CORS and any OAuth redirect URIs match.
Getting FRONTEND_URL wrong is a common cause of Single Sign-On failing with a redirect-URI mismatch, while everything else appears to work.
The backend learns who is connecting from the X-Forwarded-For header, and it only believes that header as far as it is told how many proxies stand between it and the internet. That number is TRUST_PROXY (see Configuration); the address it produces is what the sign-in rate limit counts per visitor, what the Connection Journal records, and whom an IP ban turns away - with the wrong number a ban hits your proxy (which is why private addresses cannot be banned) or misses the visitor.
-
The default is
1: the frontend container's own nginx, and nothing in front of it. That fits a plain Docker setup where you open port 3000 directly. -
A proxy of your own in front of that container counts as one more (a host nginx, Traefik, Caddy, Coolify's proxy). Set
TRUST_PROXY=2for one of them, and so on. Measured with two nginx in a row and the default: the backend took the proxy for the visitor, so a second visitor was refused ("Too many attempts") after the first one's failed logins; with2each visitor had their own limit. -
Too high is worse than too low. One proxy more than there really is, and an address the visitor wrote into the header himself is believed. Instead of a number,
TRUST_PROXYalso takes a comma-separated list of the proxies' addresses or networks (for example your Docker network), which cannot be fooled this way. -
Your proxy has to send the header. In nginx that is
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;(adapt it to whatever proxy you run). - Check it instead of counting: Settings → Network shows how your own request arrived (every address in the chain, nearest to the backend first), which one the backend takes for you, and the number that would make the first public address the visitor. Compare it with an address you know is yours; if it is not, a CDN or another proxy sits in between.
-
The backend port (3001) is published on
127.0.0.1only in the compose file. The frontend container reaches the backend over the Docker network, so it does not need the host's network. If you open that port to the outside anyway, anybody can write their ownX-Forwarded-Forand be believed, whateverTRUST_PROXYsays.
The Files page uploads a file as a single request whose body is the raw file, up to FILES_MAX_UPLOAD_MB (100 MB by default; see Configuration). The frontend container's nginx already has a location for it - no size cap, no buffering to disk first. A reverse proxy of your own in front of it needs the same treatment, or a larger upload fails with HTTP 413 (how to recognise and fix that) or runs into a timeout. An update of the stack refreshes the container's nginx but never a proxy of yours, so this is a one-time step on your side:
-
Request size. nginx's default
client_max_body_sizeis 1 MB. Raise it to at least yourFILES_MAX_UPLOAD_MB, or set it to0for the upload path and let the app enforce the limit. -
Buffering. nginx collects a whole request body before passing it on unless
proxy_request_buffering off;is set. The app copes (it asks the TeamSpeak server for a fresh transfer ticket when the body finally arrives), but it doubles the disk use and delays the start of the transfer, so turn it off for the upload path -~ ^/api/servers/[0-9]+/vs/[0-9]+/files/[0-9]+/uploads/. -
Timeouts. A big file on a slow line takes minutes; a
proxy_read_timeoutandproxy_send_timeoutof an hour for that path leave room.
In nginx that adds up to roughly the following. It is a sketch: which file it goes into depends on your distribution and on how you installed nginx, and the proxy_pass target is wherever your existing location sends the manager's traffic.
# inside the server block that serves the manager
location ~ ^/api/servers/[0-9]+/vs/[0-9]+/files/[0-9]+/uploads/[A-Za-z0-9_-]+$ {
client_max_body_size 0; # the app enforces FILES_MAX_UPLOAD_MB itself
proxy_request_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_pass http://127.0.0.1:3000; # same upstream as your other location for the manager
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}A download is a one-time link the browser opens (/api/file-downloads/...). proxy_buffering off; for that path lets nginx stream it instead of collecting the whole file first.
Separately from the proxy, the backend has to reach the TeamSpeak server's own file-transfer port (30033 by default). The query connection can work while that port is closed to the backend - the Files page then lists files fine and fails when an upload or download actually starts, saying it could not reach the file-transfer port.
The Query Console's live events arrive as one long-lived response (GET /api/servers/<id>/console/events, a server-sent-events stream). A proxy in front of the app has to pass it on as it arrives and must not close it for being idle.
-
The frontend container's own nginx does both, with nothing to configure. The backend marks the stream
X-Accel-Buffering: no, which tells nginx not to collect it, and sends a ping every 15 seconds, well inside nginx's 60-second read timeout. (Tested through that container's/apilocation: an event came through in about a tenth of a second, and the stream stayed open and still delivered after 75 seconds without any event.) - A proxy of your own in front of that has to do the same. Traefik, Caddy and CDNs have not been tested here; if events arrive in bursts or not at all while everything else works, check response buffering and idle timeouts for that path first.
- The page treats a stream that has been silent for 45 seconds as dead and opens a new one, so a proxy that drops idle connections only costs a short gap.
None of the three containers runs as root. The backend and the sidecar run as uid/gid 1000 (they share the music volume, so they have to agree), the frontend's nginx as uid 101.
-
The data volumes.
backend-dataandmusic-datahave to belong to uid1000. A new installation gets that by itself. An installation from before the change has root-owned volumes, so the compose files have a one-shot service,volume-init: it runs as root on everydocker compose up, hands both volumes to1000:1000, and exits before the sidecar and the backend start.Exited (0)next to it indocker compose ps -ais how it should look (docker compose psdoes not list it). The update itself is the usual one, nothing extra. The service changes the owner of everything inside both volumes: if you have pointedmusic-dataat a folder that another application writes to as well, take the service out of your compose file (or adapt its command) and manage that folder's ownership yourself. -
Bind mounts and compose files of your own. The service only knows the two named volumes. If you replaced them with directories on the host, or run the images without these compose files, give the directories to uid
1000once (chown -R 1000:1000 <directory>). Forget it and the backend stops right at the start withunable to open database file. -
Files that come from outside. Whatever the containers have to write must be writable for uid
1000. That includes acookies.txtmounted throughYT_COOKIE_FILE: yt-dlp rewrites it after a run and ends with aPermissionErrorif it cannot, so make it writable for uid1000or upload the cookies under Settings → YouTube instead (they then live in the data volume). A media folder you share with another application only has to be readable for the sidecar; the backend needs to write into it to add or delete files. -
The frontend listens on 8080 inside its container. An unprivileged nginx may not bind a port below 1024 on every container runtime. The compose file publishes it as
3000:8080, so whoever uses port 3000 notices nothing: a host nginx in front of it keeps pointing at127.0.0.1:3000. Whoever reaches the container directly (Coolify's domain setting, a proxy on the Docker network) has to use8080now. -
If you had changed the port mapping in your
docker-compose.yml(to127.0.0.1:3000:80, say, or another host port), the update changes the same line.git pull --autostashthen reports a conflict in that file, but ends without an error and leaves conflict markers (<<<<<<<) in it; thedocker compose upafter it refuses the file with a YAML error and changes nothing, so the old stack keeps running. Resolve the conflict in the file (keep your host side, write8080as the container side), mark it resolved withgit add, drop the leftover entry ofgit stash list, and run the update command again. The same applies to any other line of that file that you changed and the update changed too.
Settings → Update Status shows the current and latest version for backend, sidecar and frontend, when it last checked, and a button to check again. It also rechecks on every login, so what you see straight after signing in is fresh rather than whatever the six-hourly background check last cached.
Updating itself is a rebuild and restart of the stack — the app never updates itself, deliberately: doing so would require giving the backend access to the Docker socket, which is effectively root on the host.
Every start of the backend container runs the same steps before the server answers:
- It looks for a newer yt-dlp. If that fails (no network, say), it carries on with the one it has.
- It brings the database up to date through versioned migrations: a brand-new database is built from scratch, and an existing one has any migrations it is still missing applied. The first start after moving to this scheme adopts an existing database into the migration history once, without changing its data. Before a start that would actually change the database (apply a migration), it first copies the database into a
backups/folder next to it indata/(thebackend-datavolume), and only then applies the change; a start with nothing to apply takes no copy. If an existing database does not match what the migrations expect, the start stops and changes nothing (Troubleshooting). - It adds default settings that are missing, and starts.
About the copies:
- Each is named after the time it was taken and the backend version that was about to start (
…-before-2.71.0.db), so the newest copy before an update holds the data of the version you came from. -
DB_BACKUP_KEEP(default5, Configuration) sets how many are kept; the oldest go first. A copy is about as large as the database, so plan room for that many on the volume. - One copy per version: if the new version fails to start and the container restarts again and again, the copy from before the update stays and is not replaced.
- If the copy cannot be written (the volume is full, say), the container stops before it touches the database, and its log says why (Troubleshooting).
- Only the backend user can read them: they hold password hashes, sign-in tokens and the encrypted TeamSpeak credentials. Treat them like the database itself. The credentials in a copy can only be decrypted with the
ENCRYPTION_KEY(orJWT_SECRET) of the installation that wrote it.
These database steps add a second or two to every start.
During the build the old containers keep serving as usual. Only when docker compose up replaces them is nobody answering for a few seconds (more if the backend takes long to start). The same happens with the scheduled restart.
- Pages that are already open show a notice that the manager is not reachable and carry on by themselves once it answers again: they reload if the update brought a new frontend, otherwise they only fetch fresh data, so nothing typed into a form is lost. Nothing to configure.
-
A page opened in exactly that moment (a restored tab after starting the browser, say) reaches nobody. With a reverse proxy of your own in front of the manager, the visitor gets that proxy's bare
502 Bad Gatewayand has to reload by hand. The proxy can answer with a small page instead that reloads itself. In nginx, roughly the following; it is a sketch - which file it goes into depends on your distribution and setup, and the wording is yours to change:
# inside the server block that serves the manager
error_page 502 504 =503 @manager_restarting;
location @manager_restarting {
default_type text/html;
add_header Retry-After 5 always;
add_header Cache-Control no-store always;
return 503 '<!doctype html><meta charset="utf-8"><meta http-equiv="refresh" content="5"><title>Restarting</title><p>The manager is restarting. This page reloads by itself in a few seconds.</p>';
}error_page only catches the errors nginx produces itself (nobody behind the upstream address), not a 502 the manager's own container sends, so the app still sees those. Reload nginx the way your system does after checking the configuration (nginx -t). To try it, stop the frontend container for a moment (docker stop ts6-frontend): the manager's address now answers 503 with that page instead of 502; docker start ts6-frontend brings it back.
The backend and the sidecar are built on Debian 13 and the frontend on Alpine with nginx; the Dockerfiles in the repository are the whole recipe.
-
Health. Each image reports whether it answers (the backend on
/api/health, the sidecar on/health, the frontend through nginx). The status column ofdocker psordocker compose pssayshealthyonce it does; the backend takes up to a minute after a start, because it updates yt-dlp and runs the database migrations first. Docker does not restart a container for being unhealthy - it is information, for you and for monitoring. - Operating-system patches arrive with a rebuild. Every build installs the updates that are published at that moment (on top of whatever the base image already had), and a running container never patches itself. An update of the app is a rebuild, so it patches the images too; if you leave the app alone for a long time, rebuild now and then anyway, with a fresh pull of the base images.
-
Nothing is installed that nothing runs. The backend image has no
curl,npmorpnpm(and no TypeScript compiler); to try a URL from inside the Docker network, usenode- it hasfetchbuilt in. - Scanning the images yourself. The repository runs a Trivy scan of all three images and of the source every Monday and on demand; the results are in the repository's Security tab on GitHub. Expect one kind of finding to remain in any scanner: Debian packages for which Debian has not published a fix yet (they disappear with a rebuild once Debian ships one). The containers run unprivileged, so the scanner's "image user should not be root" note is gone. A finding with a fixed version listed is the signal to rebuild.
The database and the media library:
- the backend's
data/directory (or its volume) — every setting, user, connection, bot flow and music bot - whatever
MUSIC_DIRpoints at, if you care about uploaded audio and video
The automatic copies in data/backups/ (above) sit on the same volume as the database: they help after an update that went wrong, not when the disk or the volume is lost. A backup of data/ includes them.
Credentials inside the database are encrypted with ENCRYPTION_KEY, so a backup is only restorable together with that key. Keep it somewhere separate from the backup. Restored files have to belong to uid 1000 again (above); for the named volumes of the compose file the next docker compose up takes care of that.
v0.15, last edited 24.10.2026
Getting started
Using the app
Running it
When something is odd
Contributing