← Back to Projects

docker-beets

A customizable Alpine-based Beets image with plugin source overrides, version-aware patches, and a cache-conscious multi-stage build.

Goal
A highly customizable containerization of Beets music tagger
My role
Solo developer
Status
Active
Started
  • Docker
  • Python
  • Alpine Linux
  • GitHub Actions

I created docker-beets because I was having a hard time integrating the plugins I wanted using the commonly recommended lscr.io Beets image. Getting a plugin working can involve Python dependencies, native tools, a particular upstream version, or a fix that has not reached a release yet. I wanted those choices to be part of an explicit image build, so I could assemble the environment my music library needed and carry it forward when recreating the container. The project is essentially a packaging and integration layer around upstream Beets, with customization and build efficiency as its main concerns.

The image uses a multi-stage Alpine build. A dependency stage builds Python wheels for Beets, the selected plugins, and their dependencies; a separate stage applies upstream patches and produces the final Beets wheel. The runtime stage installs Python packages from that local wheelhouse using --no-index, while the additional compiler toolchain and development packages stay in the build stages. It also includes tools such as FFmpeg, Chromaprint, and ImageMagick for the audio and artwork workflows that plugins depend on. Configuration and the music library live on mounted storage, and the entrypoint maps the requested user and group IDs before dropping privileges to run the selected command.

I exposed customization at both build time and startup, but they serve different purposes. Dependencies I expect to keep belong in the image; startup installation is useful for experiments, but runs on every container start and requires network access. The major capabilities are:

  • Build a selected upstream Beets tag or branch and substitute plugin sources, including Git forks, through build arguments.
  • Bundle additional Python packages and native dependencies, with separate controls for build-only and runtime Alpine packages.
  • Apply patches within declared Beets version ranges using PEP 440 ordering, skip fixes already present upstream, and fail on applicable patches that cannot be applied cleanly.
  • Run individual Beets commands, an interactive shell, or the bundled web plugin, with configurable user IDs, group IDs, and file-creation permissions.
  • Build and publish development images through CI, then manually promote them to versioned release tags across three registries.

One implementation challenge was preventing a small upstream patch from invalidating the expensive plugin dependency work. I split the Dockerfile so third-party wheels are resolved and built before the patch directory is copied in. An unpatched Beets wheel supplies the version and dependency metadata needed by the resolver; after patching, I build only the replacement Beets wheel with --no-deps. The runtime image mirrors that separation: it installs the dependency set first, then force-reinstalls the patched Beets wheel in a later layer without resolving dependencies again. BuildKit cache mounts and the GitHub Actions layer cache support reuse across builds. That separation has an important constraint, so the build rejects applicable patches that touch dependency metadata: a dependency-changing fix cannot safely reuse a wheelhouse resolved from the unpatched source.

The release workflow is intentionally separate from the build workflow. CI checks the container’s Beets version and file-creation permissions before publishing development images to GHCR, Docker Hub, and my Forgejo registry. A manual promotion retags an existing development image for release, with an optional update to latest, rather than rebuilding it during promotion. Versioned release tags keep the chosen upstream Beets version stable, although the tags remain mutable and do not freeze every dependency. The automated checks are smoke tests, not proof that every plugin combination works; plugin and configuration compatibility still need attention when upgrading. For me, the value is having a build I can inspect and adapt when those integration problems arise, with clear places to change a dependency, test a fork, or carry a temporary fix.