Docker is the tidy way to run the bridge on a device that already hosts containers — a NAS, an Unraid / TrueNAS box, or a headless Linux server. The image runs as a non-root user, keeps every bit of state under one volume, and needs no Go toolchain and no service-manager plumbing. Prefer a native binary with a launchd / systemd / Windows-Service install? Use the setup walkthrough instead — this page is the container path.
Quickstart — one command
Point a read-only mount at your music, give the bridge a named volume for its own state, and
start it. On first boot the container writes a default config itself (the image's default
command carries --init-if-missing), scans your library, and comes up healthy —
no bridge init step.
docker compose (recommended)
Drop this compose.yaml next to wherever you keep your stacks, edit the two paths, and bring it up:
services:
bridge:
image: ghcr.io/acoseac/1-bit-bridge:latest
container_name: 1-bit-bridge
restart: unless-stopped
ports:
- "7788:7788/tcp"
- "7788:7788/udp" # HTTP/3 (QUIC) — drop this line to serve HTTP/2 only
volumes:
- "/mnt/music:/library:ro" # YOUR library, mounted read-only
- "bridge-state:/data" # persistent state — must be a named volume
environment:
BRIDGE_LIBRARY_ROOTS: "/library"
BRIDGE_LIBRARY_NAME: "1-bit Library"
BRIDGE_UPSCALE_ENABLED: "false" # optional audio features, off by default
BRIDGE_ANALYSIS_ENABLED: "false"
TZ: "UTC"
volumes:
bridge-state: docker compose up -d
docker compose logs -f # watch the first library scan
The repo ships this as a ready-to-run compose.yaml
plus an .env.example,
so you can git clone, cp .env.example .env, and docker compose up -d without editing YAML at all.
…or plain docker run
docker run -d --name 1-bit-bridge \
-p 7788:7788/tcp -p 7788:7788/udp \
-v /mnt/music:/library:ro \
-v bridge-state:/data \
-e BRIDGE_LIBRARY_ROOTS=/library \
ghcr.io/acoseac/1-bit-bridge:latest
Both TCP and UDP are published on 7788 so the API supports HTTP/3
upgrades; omit the /udp mapping and the bridge falls back to HTTP/2. Then pair iOS
against https://<host>:7788 with the fingerprint from
docker exec 1-bit-bridge bridge cert info (or the admin console — see
below).
Two things that trip people up
- Your library must be readable by the container user (UID 100). The bridge
runs non-root; if the bind-mount isn't readable to it, the scan logs
library root unreadable …and indexes 0 tracks. Fix it host-side —chmod -R a+rX /mnt/music,chownit to UID 100, or adduser: "0:0"to run as root. The:romount is never written to. - Use a named volume for
/data(as above), not a host bind-mount. A bind-mount like./data:/datais created root-owned, so the non-root process can't write its config / DB / cert into it; a named volume inherits the image's writable/data.
The published image
A pre-built multi-arch image (linux/amd64 + linux/arm64, so it runs natively on an x86 NAS or an Apple-Silicon / Raspberry-Pi host) is published to the GitHub Container Registry for every release. It's public — no docker login to pull.
docker pull ghcr.io/acoseac/1-bit-bridge:latest # newest release
docker pull ghcr.io/acoseac/1-bit-bridge:0.1.8 # a specific version
docker pull ghcr.io/acoseac/1-bit-bridge:0.1 # the 0.1.x series Pin a specific tag (:0.1.8) for reproducible deploys; :latest follows each release. Building from a clone instead? docker build -t 1-bit-bridge:dev . from the repo root, or docker buildx build --platform=linux/amd64,linux/arm64 for a multi-arch build.
Environment overrides
The bridge reads these at startup, after loading bridge.yaml — so env wins over YAML, and YAML wins over built-in defaults. Empty / unset means "no change." They're the idiomatic container knob: your compose file or k8s spec drives the config without ever editing the baked-in YAML.
| Variable | YAML key | Notes |
|---|---|---|
BRIDGE_LIBRARY_ROOTS | libraryRoots | Colon-separated list, e.g. /library:/library2. Add a matching read-only volume per root. |
BRIDGE_LIBRARY_NAME | libraryName | Display name shown in the iOS app's Sources list. |
BRIDGE_LISTEN_ADDRESS | listenAddress | Main HTTPS bind, e.g. :7788. Change it and update the published ports: to match. |
BRIDGE_UPSCALE_ENABLED | upscale.enabled | true/false. Offline PCM upscaling + CarPlay-optimize. See Audio features. |
BRIDGE_ANALYSIS_ENABLED | analysis.enabled | true/false. Waveform / loudness / key-tempo analysis. Same toolchain. |
BRIDGE_DISABLE_HTTP3 | disableHttp3 | true bypasses the UDP listeners (also drop the /udp port). |
BRIDGE_DATA_DIR | dataDir | Persistent volume path. Read Where state lives before overriding. |
TS_AUTHKEY | tailscale.authKey | Headless tsnet auth on first boot. |
TZ | — | Local timezone for the auto-updater quiet-hours window. |
The three booleans are parsed with Go's strconv.ParseBool — true/false
(also 1/0, t/f). A value it can't parse (yes, on) is
silently ignored, leaving the YAML/default in place — so a typo reads as "unchanged," not "off."
We don't rebind the admin console in these examples — it's unauthenticated and must stay on loopback in
loopback mode, so a non-loopback BRIDGE_ADMIN_ADDRESS fails validation and the bridge refuses to
start (only public mode, which adds a password login, allows a routable admin bind). Reach
it instead via docker exec or a reverse proxy.
Audio features (upscale + analysis)
As of v0.1.8 the image bundles the audio toolchain — sox,
ffmpeg, and ffprobe — so the optional
offline upscaling / CarPlay-optimize and
audio-analysis (waveform, loudness, key/tempo)
features work inside the container with nothing else to install. Both are off by
default and cost nothing until enabled. Bundling ffmpeg is what pushes
the image to roughly 260 MB — the deliberate trade for in-container audio
processing. There's no on-the-fly transcoding: the bridge pre-converts to FLAC sidecars
offline and serves them bit-exact, the same as any
other file.
Flip either feature on with an env var:
docker run -d --name 1-bit-bridge \
-p 7788:7788/tcp -p 7788:7788/udp \
-v /mnt/music:/library:ro \
-v bridge-state:/data \
-e BRIDGE_UPSCALE_ENABLED=true \
-e BRIDGE_ANALYSIS_ENABLED=true \
ghcr.io/acoseac/1-bit-bridge:latest Verify the toolchain resolved with docker exec 1-bit-bridge bridge doctor — the audio-toolchain line reports sox vX, FLAC supported when it's present. (The port-api / port-admin checks will read in use inside a running container — that's expected: bridge serve already holds those ports.)
Variant storage. Upscaled / optimized FLAC sidecars land under upscale.variantsDir (default <dataDir>/transcoded, inside /data). To keep them off the state volume, mount a dedicated volume and point variantsDir at it — but a separately-mounted volume is created root:root by Docker, so pre-create and chown it to the container user first, or the non-root process can't write sidecars:
mkdir -p ./transcoded && sudo chown 100:100 ./transcoded
# then mount -v ./transcoded:/transcoded and set upscale.variantsDir: /transcoded Where state lives (/data)
Everything the bridge writes — bridge.yaml, the SQLite manifest, the TLS cert/key, the ACME cache (public mode), the artwork and booklet caches — lives under the /data volume, so a container restart or image upgrade keeps your pairings, tokens, and enrichment.
One wrinkle worth knowing. The auto-init path writes a config with a
relative dataDir: data, which resolves next to the config at
/data/bridge.yaml — so state actually nests one level down under
/data/data/… (e.g. /data/data/bridge.db). It's harmless — it's all
inside the /data volume and fully persisted — just don't be surprised the DB isn't
at /data/bridge.db. Leave BRIDGE_DATA_DIR unset so the layout stays
fixed; setting it to /data flattens it, but then you must keep that env var set on
every start.
Admin console access
The admin console binds 127.0.0.1:7789 inside the container and is unauthenticated — so it's deliberately not published to the host. Two safe patterns:
One-off ops via docker exec:
docker exec 1-bit-bridge bridge status
docker exec 1-bit-bridge bridge cert info # the pairing fingerprint
docker exec 1-bit-bridge bridge library add /library2 Browser access via a reverse proxy (advanced). If you genuinely need the UI from a browser, run Caddy / Traefik / nginx with HTTP basic auth in a sidecar that shares the bridge's network namespace (network_mode: "service:1-bit-bridge" in compose), so 127.0.0.1:7789 resolves to the bridge's own loopback rather than the proxy's. The loopback-only bind is what stops anonymous access — don't just port-forward 7789 to the host.
Tailscale (tsnet)
Want the bridge reachable over your tailnet without exposing it to the LAN or the public internet? The image supports Tailscale's embedded userspace node (tsnet) — it joins the tailnet from inside the container with no /dev/net/tun, no NET_ADMIN, and no --network host, so it needs no extra Docker privileges.
Two things to know first: the runtime image ships no tailscale CLI, so the default tailscale.mode: cli is a silent no-op in a container — use tsnet mode. And tsnet isn't expressible through the BRIDGE_* env vars, so set it in bridge.yaml and pass the auth key through TS_AUTHKEY. Minimal config, dropped into the /data volume:
libraryRoots:
- /library
listenAddress: ":7788"
adminAddress: "127.0.0.1:7789"
tailscale:
mode: tsnet
hostname: onebit-bridge # the node name in your tailnet docker run -d --name 1-bit-bridge \
-e TS_AUTHKEY=tskey-auth-xxxxxxxxxxxxxxxxxx \
-v bridge-state:/data \
-v /mnt/music:/library:ro \
ghcr.io/acoseac/1-bit-bridge:latest - The tailnet identity persists at
<dataDir>/tailscale, soTS_AUTHKEYis only needed on the first start. - iOS pairs against the node's MagicDNS name (
https://onebit-bridge.<your-tailnet>.ts.net); the bridge serves a real Let's Encrypt cert for.ts.netin-process. Your tailnet must have HTTPS certificates enabled. - You don't publish any host ports for the tailnet path — traffic arrives over the tailnet. Publish
7788only if you also want plain LAN access alongside it.
Public mode (internet-exposed)
By default the container runs in loopback mode — the right posture for a LAN or Tailscale deployment, and what everything above assumes. For a bridge intentionally exposed to the public internet, the bridge has a public mode that adds a single-user admin password and a Let's Encrypt certificate. It needs a one-time init --public to generate the config (it prints the admin password once — capture it):
docker run --rm -it \
-v bridge-state:/data \
-v /mnt/music:/library:ro \
ghcr.io/acoseac/1-bit-bridge:latest \
init --public --yes --no-service \
--dir /data --library /library \
--domain bridge.example.com --email you@example.com Public mode binds the API on :443 (Let's Encrypt's TLS-ALPN-01 challenge validates only on TCP/443). The non-root container user can't bind a privileged port by default — allow it with --sysctl net.ipv4.ip_unprivileged_port_start=0 and publish 443 (TCP + UDP):
docker run -d --name 1-bit-bridge \
--sysctl net.ipv4.ip_unprivileged_port_start=0 \
-p 443:443/tcp -p 443:443/udp \
-v bridge-state:/data \
-v /mnt/music:/library:ro \
ghcr.io/acoseac/1-bit-bridge:latest Keep the ACME cache on a persistent volume. The Let's Encrypt cert + account
key live at <dataDir>/acme. If /data is ephemeral, the bridge
re-requests the cert every start and hits Let's Encrypt's duplicate-certificate rate limit
(5 per domain per week), after which issuance fails for days. Back /data with a
real named volume.
The full public-VPS story — firewalling, the QUIC host sysctls, reverse-proxy TLS termination (--public --proxy), and worker-count tuning on shared hosts — lives in the public-VPS deployment runbook on GitHub.
Logs
The container logs to stdout/stderr — no service-manager log file, so use Docker's own tools:
docker logs -f 1-bit-bridge The in-container bridge logs subcommand won't work — it tails the host-side service-install log path, which doesn't exist in a container. Bearer tokens are never logged; library-relative file paths appear only in error lines.
Troubleshooting
- iOS can't connect. Confirm
7788is published and the host firewall (ufw,firewalld, Synology Security Advisor) isn't blocking it. Check the fingerprint iOS expects withdocker exec 1-bit-bridge bridge cert info. - Library indexes 0 tracks. The bind-mount isn't readable to UID 100 (the container user). Fix host-side ownership / permissions, or run as root with
user: "0:0"(compose) /--user 0:0(run). - First scan never finishes. Tail
docker logs -f. Most stalls are NFS-mount or permission flaps — the scanner spares affected subtrees from the deletion pass on transient errors, so a re-scan after fixing the mount lands cleanly. - Audio features won't turn on. Run
docker exec 1-bit-bridge bridge doctorand read theaudio-toolchainline. The toolchain is bundled, so this almost always means the feature env var is unset or was given an unparseable value (yes/onare ignored — usetrue).
For the exhaustive reference — every knob, the multi-root and public-mode compose examples, and the worker-tuning notes — see the Docker guide on GitHub. Symptom-to-fix for the app itself is on the troubleshooting page.