Install with Docker Compose¶
About 5 minutes from zero to running.
Before you start¶
You'll need:
- Docker and Docker Compose installed. On Linux,
docker --versionanddocker compose versionshould both work. macOS and Windows users can install Docker Desktop. - A directory containing your music library. Stillwater needs to read it, and (for NFO writeback) write
artist.nfoand image files into it. The directory can be local or a network share that's mounted on the Docker host. -
The host user ID and group ID that own that music directory. Find them with:
Note the
uid=andgid=numbers. You'll plug them into the compose file in a moment.
What gets deployed¶
One container running the Stillwater binary. Two storage paths:
/config(a Docker named volume): SQLite database, generated encryption key, optionalconfig.toml, and any backups Stillwater writes itself./music(a bind mount to your host's music directory): the library Stillwater reads from and writes NFO files into.
Stillwater listens on port 1973 inside the container. The compose file publishes it to 1973 on the host, so you'll reach the web UI at http://localhost:1973 once it's up.
The compose file¶
Save this to docker-compose.yml in a new directory (anywhere; the directory just needs to be writable and somewhere you can run docker compose from).
services:
stillwater:
image: ghcr.io/sydlexius/stillwater:latest
container_name: stillwater
ports:
- "1973:1973"
environment:
- PUID=1000
- PGID=1000
- SW_LOG_LEVEL=info
- SW_LOG_FORMAT=json
# Keep this equal to the `cpus` value below.
- GOMAXPROCS=2
# Keep this at about 80% of `mem_limit` below.
- GOMEMLIMIT=2400MiB
# SW_ENCRYPTION_KEY is auto-generated on first run if not set.
# SW_BASE_PATH=/stillwater # Uncomment for subfolder reverse proxy.
volumes:
- stillwater-data:/config
- /path/to/your/music:/music:rw
restart: unless-stopped
cpus: "2.0"
pids_limit: 512
mem_limit: 3g
mem_reservation: 512m
ulimits:
nofile:
soft: 8192
hard: 8192
volumes:
stillwater-data:
Customize the compose file¶
Two required edits before you start the stack, plus one recommended:
-
Replace
/path/to/your/musicwith the absolute path to your music library on the host. For example,/srv/media/musicor/Users/you/Music. -
Set
PUIDandPGIDto the values fromidyou noted earlier. The container drops privileges to this user so files Stillwater writes to your music directory are owned correctly. Mismatch here is the most common source of permission errors.Stillwater needs write access to
/music, not ownership: it never recursively changes ownership of your library. That is deliberate, because a music share is often mounted into several containers at once, and re-owning it would lock the others out. Make sure the mount is writable byPUID:PGID(group-writable is the common arrangement) and new files Stillwater creates will be owned by that pair. -
(Optional) Pin a version tag instead of
:latest. For production setups:Available tags are listed on the GitHub releases page.
Other knobs you may not need to touch:
- Port. If
1973is taken on your host, change the left side of the port mapping (for example,"3000:1973"to expose Stillwater on port 3000). SW_LOG_FORMAT.jsonis right for log aggregators; switch totextfor friendlier console output during setup.SW_ENCRYPTION_KEY. Stillwater encrypts third-party API keys at rest. On first run it generates a key into/config/encryption.keyand uses it from then on. You only need to set this env var if you're restoring from a backup that was encrypted with a known key.
Resource Limits¶
The compose file bounds what the container can consume. This file uses the service-level Compose Spec keys so docker compose up applies them. Modern Docker Compose also supports deploy.resources outside Swarm, but if both service-level keys and deploy.resources are declared, they must be consistent with each other.
| Key | Value | What it bounds |
|---|---|---|
cpus + GOMAXPROCS |
2.0 / 2 |
CPU time |
pids_limit |
512 |
Processes and OS threads |
ulimits.nofile |
8192 |
Open file descriptors |
mem_limit + GOMEMLIMIT |
3g / 2400MiB |
Memory |
mem_reservation |
512m |
Memory, soft limit under host contention |
-
CPU. Two cores matches
SW_RULE_ENGINE_ARTIST_WORKERS, which defaults to 2 and is the widest deliberate concurrency in Stillwater. Reaching the limit throttles rather than fails: a rules pass or a scan takes longer, and nothing errors. SetGOMAXPROCSto the same number, becausecpuson its own constrains the container through the kernel scheduler without informing the Go runtime, which then runs more work in parallel than the quota can absorb. If sweeps feel slow on a machine with cores to spare, you can raisecpus/GOMAXPROCStogether (4.0and4) -- but raisingSW_RULE_ENGINE_ARTIST_WORKERSalso raises the memory peak documented under Memory below proportionally, somem_limitandGOMEMLIMITneed to move with it too, not just the CPU pair. -
Processes.
512is a backstop against a runaway, not a working ceiling. Stillwater's steady-state thread count is far below it, so you should never approach this number in normal operation. Leave it high: like the memory limit, and unlike the CPU and file-descriptor limits, exhausting the process limit is fatal to the container rather than degrading. -
File descriptors.
8192sits well above any healthy peak. It is set generously on purpose, because a meaningful share of Stillwater's descriptors are sockets to Emby, Jellyfin, and Lidarr, and how many of those are open at once depends partly on how those services behave rather than only on what Stillwater is doing. Running out degrades: file opens are logged and skipped, outbound connections surface as a request error, and the filesystem watcher falls back to polling. If you tune it, do not go below2048. -
Memory. Two settings that work as a pair, and the order matters.
GOMEMLIMITis a soft ceiling the Go garbage collector honors: as the heap approaches it the collector works harder, so memory pressure shows up as slower passes -- for ordinary garbage.mem_limitis the hard ceiling, enforced by the kernel's OOM killer, which terminates the process outright with no chance to flush state or shut down cleanly.GOMEMLIMITis set to roughly 80% ofmem_limit(2400/3072 = 78%) so the soft control has room to act before the hard one fires; the remaining margin covers the parts of the process the Go heap does not account for. If you change one, change both. For the image-decode workload described next, treat the two as a genuine pair rather than a soft-then-hard sequence: the bytes involved are live objects Stillwater is actively holding, not reclaimable garbage, soGOMEMLIMITpressure cannot make them go away faster.3gis derived from the corrected worst case Stillwater can actually reach, not picked as a round number. Every path that reads an image file caps the read at 25 MB, but the buffer briefly holds about twice that while it grows, so budget 50-60 MB per read -- a small term next to decode. Decoding is the larger cost, and larger than a simple pixel-count times 4 bytes suggests: the 100-megapixel cap bounds pixel count, but the Go image decoder picks the concrete type, and a 16-bit-per-channel source decodes to 8 bytes per pixel rather than 4, so the worst case is about 800 MB, not 400 MB, and that is a separate allocation from the bytes it was decoded from. The logo-trim path compounds this: it allocates a second full-size buffer for the cropped result (about 400 MB at 4 bytes per pixel) that is live at the same time as the ~800 MB decoded source, so one trim peaks around 1.2 GB.SW_RULE_ENGINE_ARTIST_WORKERS(default 2) bounds concurrency on the rules-pass path, so two trims can run at once: about 2.3 GiB, which is close to a measured peak of 2293 MiB for that exact scenario. Against the 3g ceiling that leaves roughly 779 MiB for SQLite, HTTP, the update stream, the library scanner, and goroutine stacks -- real headroom, but closer to 1.3x the peak than 3x.ArtistWorkersonly bounds the rules-pass path: the logo-trim API endpoint, the image upload path, and placeholder generation reached from the scanner all decode on their own request goroutine with no separate concurrency limit, so a burst of concurrent requests is not capped by this setting. A dedicated limit for that path is tracked separately;mem_limitis the backstop for it today.These are fixed per-image bounds, so the number does not grow with your library. A larger library makes a sweep take longer, not consume more memory. Raise
mem_limit(andGOMEMLIMITwith it) only if you raiseSW_RULE_ENGINE_ARTIST_WORKERS, since that is what widens the concurrent-decode term on the rules-pass path.If the hard limit is ever reached, files already on disk are safe: Stillwater stages every NFO and image write in a temporary file and installs it with a single rename, so no file is left half-written. What is lost is the work in flight, meaning a rules pass in progress has to be re-run. Because
restart: unless-stoppedwould restart into the same condition, the limit is set far enough above the real peak that reaching it indicates a genuine leak rather than ordinary work. -
Memory reservation.
mem_reservationis a soft memory limit enforced by the kernel under host memory contention. It does not reserve capacity or make placement decisions in plain Compose. When memory is plentiful the container may exceed it, up tomem_limit; when the host is under memory pressure, the kernel tries to push the container toward this figure.512mis a conservative chosen value below the transient peak above, not a measured steady-state figure.
Bring it up¶
From the directory containing docker-compose.yml:
You should see Stillwater start, run any pending database migrations, and report listening on :1973. Open http://localhost:1973 in a browser. The first-time setup wizard greets you.
Day-to-day operations¶
# Tail logs
docker compose logs -f stillwater
# Restart the container (no data loss)
docker compose restart stillwater
# Stop the container
docker compose stop
# Open a shell inside the container
docker compose exec stillwater sh
Upgrading¶
If you pinned a version tag, edit the image: line first, then run the same two commands.
Backups¶
The stillwater-data named volume holds everything Stillwater needs to restore: database, encryption key, config. Back it up with a one-shot tar container. Compose prefixes named volumes with the project name (the project directory's basename, by default), so the example below discovers the actual volume name dynamically:
VOL=$(docker volume ls --format '{{.Name}}' | grep '_stillwater-data$')
if [ -z "${VOL}" ]; then
echo "No volume matching '*_stillwater-data' found." >&2
echo "Run 'docker volume ls' and pick the right name, or set COMPOSE_PROJECT_NAME / use 'docker compose -p' to disambiguate." >&2
exit 1
fi
if [ "$(printf '%s\n' "${VOL}" | wc -l | tr -d ' ')" -gt 1 ]; then
printf 'Multiple matching volumes:\n%s\n' "${VOL}" >&2
echo "Set VOL=<exact_name> manually before running this command." >&2
exit 1
fi
docker run --rm \
-v "${VOL}:/data:ro" \
-v "$PWD":/backup \
alpine tar czf "/backup/stillwater-$(date +%F).tar.gz" -C /data .
Scheduled backups are on by default. Tune them via the web UI or these env vars:
- SW_BACKUP_ENABLED=true
- SW_BACKUP_INTERVAL=24 # hours between scheduled backups
- SW_BACKUP_RETENTION=7 # number of recent backups to keep
Backups land in /config/backups inside the volume by default.
Just want docker run?¶
For one-off testing or environments where Compose isn't available:
docker run -d \
--name stillwater \
-p 1973:1973 \
-v stillwater-data:/config \
-v /path/to/your/music:/music:rw \
-e PUID=1000 \
-e PGID=1000 \
--restart unless-stopped \
ghcr.io/sydlexius/stillwater:latest
The Compose form above is recommended for anything beyond a quick try; backups, log rotation, and reverse-proxy setups all assume a Compose-managed stack.
Troubleshooting¶
See Installation > Docker / Compose in the troubleshooting docs.