From f249e62ece0f3106d90a7e79e563325f44e453a3 Mon Sep 17 00:00:00 2001
From: Thales Pereira <31625914+thcp@users.noreply.github.com>
Date: Fri, 10 Jul 2026 21:31:15 +0100
Subject: [PATCH 1/7] feat(unraid): publish container to GHCR and add Community
Applications template
- add docker-publish workflow: build build/Dockerfile and push
ghcr.io/stemdeckapp/stemdeck on release + manual dispatch (linux/amd64)
- add templates/stemdeck.xml: Unraid Docker template (port 8000,
/app/jobs + /cache volumes, persistent library default, optional NVIDIA
runtime vars)
- add ca_profile.xml at repo root for the CA submission scan
- document the GHCR image and Unraid install in README
The published image keeps the default Linux x86_64 (CUDA) torch wheel, so a
single image runs on CPU by default and uses the GPU when started with
--runtime=nvidia; _detect_device() auto-selects CUDA.
---
.github/workflows/docker-publish.yml | 85 ++++++++++++++++++++++++++++
README.md | 21 +++++++
ca_profile.xml | 12 ++++
templates/stemdeck.xml | 57 +++++++++++++++++++
4 files changed, 175 insertions(+)
create mode 100644 .github/workflows/docker-publish.yml
create mode 100644 ca_profile.xml
create mode 100644 templates/stemdeck.xml
diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml
new file mode 100644
index 00000000..23d33e93
--- /dev/null
+++ b/.github/workflows/docker-publish.yml
@@ -0,0 +1,85 @@
+name: Docker Publish
+
+# Builds the server container (build/Dockerfile) and pushes it to GHCR so it can
+# be pulled by self-hosted deployments and the Unraid Community Applications app
+# (ghcr.io/stemdeckapp/stemdeck). The image keeps the default Linux x86_64 torch
+# wheel, which is the CUDA build -- so the same image runs on CPU by default and
+# uses the GPU automatically when started with `--runtime=nvidia` (see the
+# Unraid template in templates/unraid/).
+
+on:
+ release:
+ types: [published]
+ # Manual build/push without cutting a release. Defaults to a dev version and
+ # only tags :edge so it never clobbers :latest.
+ workflow_dispatch:
+ inputs:
+ version:
+ description: "Version for the build (valid PEP 440, e.g. 0.0.0)"
+ required: false
+ default: "0.0.0"
+
+permissions: {}
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+env:
+ IMAGE: ghcr.io/${{ github.repository_owner }}/stemdeck
+
+jobs:
+ build-and-push:
+ runs-on: ubuntu-latest
+ timeout-minutes: 60
+ permissions:
+ contents: read
+ packages: write
+ steps:
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+
+ # Derive the version fed to the Dockerfile's VERSION build-arg. On a
+ # release, use the tag with any leading "v" stripped; on manual runs, use
+ # the provided input.
+ - name: resolve version
+ id: ver
+ run: |
+ if [ "${{ github.event_name }}" = "release" ]; then
+ v="${{ github.event.release.tag_name }}"
+ else
+ v="${{ github.event.inputs.version }}"
+ fi
+ echo "value=${v#v}" >> "$GITHUB_OUTPUT"
+
+ - name: docker metadata (tags/labels)
+ id: meta
+ uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
+ with:
+ images: ${{ env.IMAGE }}
+ tags: |
+ type=raw,value=latest,enable=${{ github.event_name == 'release' && !github.event.release.prerelease }}
+ type=raw,value=${{ steps.ver.outputs.value }},enable=${{ github.event_name == 'release' }}
+ type=raw,value=edge,enable=${{ github.event_name == 'workflow_dispatch' }}
+
+ - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
+
+ - name: log in to GHCR
+ uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: build and push
+ uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
+ with:
+ context: .
+ file: build/Dockerfile
+ # Unraid is x86_64; arm64 has no CUDA torch and is not a target.
+ platforms: linux/amd64
+ push: true
+ build-args: |
+ VERSION=${{ steps.ver.outputs.value }}
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ provenance: false
diff --git a/README.md b/README.md
index feb4ab62..8cca4e0c 100644
--- a/README.md
+++ b/README.md
@@ -247,6 +247,27 @@ docker compose -f build/docker-compose.yml up --build
Stems land in `./jobs/` on the host. Demucs weights are cached in a named volume so they don't re-download on rebuild. Note: no GPU passthrough on macOS Docker.
+A prebuilt image is published to GHCR on each release:
+
+```sh
+docker run -d --name stemdeck -p 8000:8000 \
+ -v /path/to/jobs:/app/jobs \
+ -v /path/to/cache:/cache \
+ -e STEMDECK_PERSIST_LIBRARY=1 \
+ ghcr.io/stemdeckapp/stemdeck:latest
+```
+
+On a Linux host with an NVIDIA GPU (driver + NVIDIA Container Toolkit installed), add `--runtime=nvidia -e NVIDIA_VISIBLE_DEVICES=all` and StemDeck auto-detects CUDA. The image already bundles CUDA-enabled torch, so no separate CUDA install is needed.
+
+#### Unraid
+
+StemDeck is available in Unraid Community Applications: open **Apps**, search "StemDeck", and install. Map the two volumes to persistent appdata paths:
+
+- `/app/jobs` -> `/mnt/user/appdata/stemdeck/jobs` (library + stems)
+- `/cache` -> `/mnt/user/appdata/stemdeck/cache` (model weights)
+
+The library is persistent by default (`STEMDECK_PERSIST_LIBRARY=1`), so tracks are never auto-deleted. For GPU acceleration, install the **Nvidia Driver** plugin, then set the container's Extra Parameters to `--runtime=nvidia` (the `NVIDIA_VISIBLE_DEVICES` and `NVIDIA_DRIVER_CAPABILITIES` variables are already in the template). CPU-only works with no extra configuration.
+
#### `run.sh` control script
```sh
diff --git a/ca_profile.xml b/ca_profile.xml
new file mode 100644
index 00000000..b8ecce83
--- /dev/null
+++ b/ca_profile.xml
@@ -0,0 +1,12 @@
+
+
+
+ StemDeck splits any song into isolated stems (vocals, drums, bass, guitar,
+ piano, other) and plays them back in a DAW-style multitrack player, for
+ practice and transcription. This repository maintains the official StemDeck
+ self-hosted server container. For help, open an issue on GitHub.
+
+ https://raw.githubusercontent.com/stemdeckapp/stemdeck/main/desktop/src-tauri/icons/icon.png
+ https://github.com/stemdeckapp/stemdeck
+ https://github.com/stemdeckapp/stemdeck/issues
+
diff --git a/templates/stemdeck.xml b/templates/stemdeck.xml
new file mode 100644
index 00000000..855635a5
--- /dev/null
+++ b/templates/stemdeck.xml
@@ -0,0 +1,57 @@
+
+
+ StemDeck
+ ghcr.io/stemdeckapp/stemdeck:latest
+ https://github.com/stemdeckapp/stemdeck/pkgs/container/stemdeck
+ bridge
+ sh
+ false
+ https://github.com/stemdeckapp/stemdeck/issues
+ https://github.com/stemdeckapp/stemdeck
+ https://raw.githubusercontent.com/stemdeckapp/stemdeck/main/templates/stemdeck.xml
+ https://raw.githubusercontent.com/stemdeckapp/stemdeck/main/README.md
+ https://raw.githubusercontent.com/stemdeckapp/stemdeck/main/desktop/src-tauri/icons/icon.png
+ http://[IP]:[PORT:8000]/
+
+
+ MediaApp:Music MediaApp:Video Tools:
+ stems demucs vocal remover karaoke isolation multitrack youtube audio separation
+ Optional: install the Unraid "Nvidia Driver" plugin and set Extra Parameters to --runtime=nvidia for GPU acceleration. Runs on CPU without a GPU.
+
+ StemDeck splits any song into isolated stems (vocals, drums, bass, guitar,
+ piano, other) and plays them back in a DAW-style multitrack player. Paste a
+ YouTube URL or upload a file, get separated tracks you can mute, solo, loop,
+ slow down, and pitch-shift for practice and transcription. Powered by Demucs.
+
+ Data: processed tracks and downloads live under /app/jobs; Demucs model
+ weights and the torch cache live under /cache. Map both to persistent
+ appdata paths so nothing re-downloads on update.
+
+ The library is persistent and user-managed on Unraid
+ (STEMDECK_PERSIST_LIBRARY=1 is set by default); tracks are never auto-deleted.
+
+ GPU (optional): install the Unraid "Nvidia Driver" plugin, then set Extra
+ Parameters to --runtime=nvidia and keep NVIDIA_VISIBLE_DEVICES /
+ NVIDIA_DRIVER_CAPABILITIES. StemDeck auto-detects CUDA and uses it,
+ processing several times faster. Without a GPU it runs on CPU with no extra
+ configuration. Note: very new Blackwell cards (RTX 50 series) may fall back
+ to CPU until a newer CUDA build ships.
+
+ Split songs into stems and play them in a DAW-style multitrack player. Self-hosted, GPU-accelerated with the Unraid Nvidia Driver plugin.
+
+ 8000
+
+ /mnt/user/appdata/stemdeck/jobs
+
+ /mnt/user/appdata/stemdeck/cache
+
+ 1
+
+ 1200
+
+ 3
+
+ all
+
+ all
+
From 427c167ea3935e14477f9437809ae9c501e85a21 Mon Sep 17 00:00:00 2001
From: Thales Pereira <31625914+thcp@users.noreply.github.com>
Date: Fri, 10 Jul 2026 21:38:48 +0100
Subject: [PATCH 2/7] ci(unraid): derive manual-dispatch version from git
instead of 0.0.0
Drop the workflow_dispatch version input and compute it with git describe
(hatch-vcs style) so manual builds carry a real dev version. Fetch full
history + tags on checkout so git describe resolves.
---
.github/workflows/docker-publish.yml | 19 +++++++++----------
1 file changed, 9 insertions(+), 10 deletions(-)
diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml
index 23d33e93..89c457bd 100644
--- a/.github/workflows/docker-publish.yml
+++ b/.github/workflows/docker-publish.yml
@@ -10,14 +10,10 @@ name: Docker Publish
on:
release:
types: [published]
- # Manual build/push without cutting a release. Defaults to a dev version and
- # only tags :edge so it never clobbers :latest.
+ # Manual build/push without cutting a release. The version is derived from git
+ # (matching the project's hatch-vcs scheme) and the image only tags :edge, so
+ # it never clobbers :latest.
workflow_dispatch:
- inputs:
- version:
- description: "Version for the build (valid PEP 440, e.g. 0.0.0)"
- required: false
- default: "0.0.0"
permissions: {}
@@ -37,17 +33,20 @@ jobs:
packages: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ # Full history + tags so git describe can derive a version on manual runs.
+ fetch-depth: 0
# Derive the version fed to the Dockerfile's VERSION build-arg. On a
- # release, use the tag with any leading "v" stripped; on manual runs, use
- # the provided input.
+ # release, use the tag; on manual runs, derive it from git (hatch-vcs
+ # style, e.g. 0.8.0a6.dev3+g). Leading "v" stripped either way.
- name: resolve version
id: ver
run: |
if [ "${{ github.event_name }}" = "release" ]; then
v="${{ github.event.release.tag_name }}"
else
- v="${{ github.event.inputs.version }}"
+ v="$(git describe --tags --dirty --always)"
fi
echo "value=${v#v}" >> "$GITHUB_OUTPUT"
From 0c8d7ae415af00e0a26dfff7764b25f5139ec57e Mon Sep 17 00:00:00 2001
From: Thales Pereira <31625914+thcp@users.noreply.github.com>
Date: Fri, 10 Jul 2026 21:41:28 +0100
Subject: [PATCH 3/7] ci(unraid): publish a rolling :edge image on merge to
main
Add a push trigger on main so every merge builds and pushes
ghcr.io/stemdeckapp/stemdeck:edge. :edge never moves :latest, which stays
reserved for stable releases.
---
.github/workflows/docker-publish.yml | 10 ++++++----
1 file changed, 6 insertions(+), 4 deletions(-)
diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml
index 89c457bd..8da1a27c 100644
--- a/.github/workflows/docker-publish.yml
+++ b/.github/workflows/docker-publish.yml
@@ -8,11 +8,13 @@ name: Docker Publish
# Unraid template in templates/unraid/).
on:
+ # Every merge to main publishes a rolling :edge image. The version is derived
+ # from git (hatch-vcs style); :edge never clobbers :latest.
+ push:
+ branches: [main]
release:
types: [published]
- # Manual build/push without cutting a release. The version is derived from git
- # (matching the project's hatch-vcs scheme) and the image only tags :edge, so
- # it never clobbers :latest.
+ # Same as a main push but on demand, from any ref.
workflow_dispatch:
permissions: {}
@@ -58,7 +60,7 @@ jobs:
tags: |
type=raw,value=latest,enable=${{ github.event_name == 'release' && !github.event.release.prerelease }}
type=raw,value=${{ steps.ver.outputs.value }},enable=${{ github.event_name == 'release' }}
- type=raw,value=edge,enable=${{ github.event_name == 'workflow_dispatch' }}
+ type=raw,value=edge,enable=${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }}
- uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
From 24f986516540a93912cf53d0ecb49de3076541e3 Mon Sep 17 00:00:00 2001
From: Thales Pereira <31625914+thcp@users.noreply.github.com>
Date: Fri, 10 Jul 2026 21:42:35 +0100
Subject: [PATCH 4/7] chore(unraid): point template at :edge until a stable
release exists
---
templates/stemdeck.xml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/templates/stemdeck.xml b/templates/stemdeck.xml
index 855635a5..00574c6a 100644
--- a/templates/stemdeck.xml
+++ b/templates/stemdeck.xml
@@ -1,7 +1,7 @@
StemDeck
- ghcr.io/stemdeckapp/stemdeck:latest
+ ghcr.io/stemdeckapp/stemdeck:edge
https://github.com/stemdeckapp/stemdeck/pkgs/container/stemdeck
bridge
sh
From 215679605afe59963d21d73e6cb8f164f3454f27 Mon Sep 17 00:00:00 2001
From: Thales Pereira <31625914+thcp@users.noreply.github.com>
Date: Fri, 10 Jul 2026 21:43:47 +0100
Subject: [PATCH 5/7] docs(unraid): document edge/latest/version image tags and
use :edge in the run example
---
README.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index 8cca4e0c..c368d95f 100644
--- a/README.md
+++ b/README.md
@@ -247,14 +247,14 @@ docker compose -f build/docker-compose.yml up --build
Stems land in `./jobs/` on the host. Demucs weights are cached in a named volume so they don't re-download on rebuild. Note: no GPU passthrough on macOS Docker.
-A prebuilt image is published to GHCR on each release:
+A prebuilt image is published to GHCR. Tags: `edge` (rolling, rebuilt on every merge to main), `latest` (newest stable release), and `X.Y.Z` (pinned to a release).
```sh
docker run -d --name stemdeck -p 8000:8000 \
-v /path/to/jobs:/app/jobs \
-v /path/to/cache:/cache \
-e STEMDECK_PERSIST_LIBRARY=1 \
- ghcr.io/stemdeckapp/stemdeck:latest
+ ghcr.io/stemdeckapp/stemdeck:edge
```
On a Linux host with an NVIDIA GPU (driver + NVIDIA Container Toolkit installed), add `--runtime=nvidia -e NVIDIA_VISIBLE_DEVICES=all` and StemDeck auto-detects CUDA. The image already bundles CUDA-enabled torch, so no separate CUDA install is needed.
From 104ee5a7725067b9d4631d877c7f80283ae65c11 Mon Sep 17 00:00:00 2001
From: Thales Pereira <31625914+thcp@users.noreply.github.com>
Date: Fri, 10 Jul 2026 21:47:51 +0100
Subject: [PATCH 6/7] chore: default run.sh PORT to 8000 to match the
container/Unraid port
---
run.sh | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/run.sh b/run.sh
index a4403782..826ab1ed 100755
--- a/run.sh
+++ b/run.sh
@@ -6,7 +6,7 @@ set -euo pipefail
cd "$(dirname "$0")"
HOST="${HOST:-0.0.0.0}"
-PORT="${PORT:-8080}"
+PORT="${PORT:-8000}"
RELOAD="${RELOAD:-0}"
# Treat the self-hosted server as a persistent, user-managed library (like the
# desktop app): opt out of the 24h job TTL sweep so processed tracks are not
From e8cfdf6ef1f8572722e53e096510f4a5cf257583 Mon Sep 17 00:00:00 2001
From: Thales Pereira <31625914+thcp@users.noreply.github.com>
Date: Fri, 10 Jul 2026 21:52:12 +0100
Subject: [PATCH 7/7] chore: default advertised port to 8000 across backend and
desktop
Align DEFAULT_PORT (app/core/settings.py) and the desktop launcher's
configured_port() fallback (desktop/src-tauri/src/main.rs) from 8080 to 8000
so every path -- container, run.sh, and desktop -- shares one default. Update
the settings comment and the port-default test accordingly.
---
app/core/settings.py | 4 ++--
desktop/src-tauri/src/main.rs | 2 +-
tests/test_network_gate.py | 2 +-
3 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/app/core/settings.py b/app/core/settings.py
index 93cdf121..1b7e9f7e 100644
--- a/app/core/settings.py
+++ b/app/core/settings.py
@@ -30,7 +30,7 @@
_DURATION_MIN, _DURATION_MAX = 60, 1200 # 1 min .. 20 min
_HEIGHT_MIN, _HEIGHT_MAX = 144, 2160
_PORT_MIN, _PORT_MAX = 1024, 65535
-DEFAULT_PORT = 8080
+DEFAULT_PORT = 8000
def _default_allow_network() -> bool:
@@ -124,7 +124,7 @@ def set_video_max_height(value: int) -> int:
# ── port ──
# The preferred port the server binds on launch. The desktop launcher reads this
-# (default 8080) before spawning the backend; a self-hosted server's --port wins.
+# (default 8000) before spawning the backend; a self-hosted server's --port wins.
# Changing it needs a restart — the socket is bound at startup.
def get_port() -> int:
with _LOCK:
diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs
index ed2bfae1..bcd2fd30 100644
--- a/desktop/src-tauri/src/main.rs
+++ b/desktop/src-tauri/src/main.rs
@@ -1857,7 +1857,7 @@ fn free_port() -> Result<(u16, TcpListener), String> {
/// The user's preferred port (Settings -> port), read from the backend's
/// settings.json before launch. Defaults to 8080.
fn configured_port() -> u16 {
- const DEFAULT_PORT: u16 = 8080;
+ const DEFAULT_PORT: u16 = 8000;
let Ok(data_dir) = local_data_dir() else {
return DEFAULT_PORT;
};
diff --git a/tests/test_network_gate.py b/tests/test_network_gate.py
index ed6c90e0..033745f2 100644
--- a/tests/test_network_gate.py
+++ b/tests/test_network_gate.py
@@ -72,7 +72,7 @@ def test_runtime_settings_round_trip_and_clamp():
def test_port_default_and_clamp():
- assert settings_mod.get_port() == 8080 # default
+ assert settings_mod.get_port() == 8000 # default
with TestClient(app) as c:
assert c.post("/api/settings", json={"port": 9000}).json()["port"] == 9000
assert settings_mod.set_port(80) == 1024 # floor (privileged ports rejected)