1-bit

Bridge · Docker

Run the bridge in Docker

Homelab, NAS (Unraid / TrueNAS / Synology), or any headless Linux host. As of v0.1.8 it's one command — the pre-built multi-arch image bundles the audio toolchain and self-initialises on first boot, so there's no separate init step and no config file to write.

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, chown it to UID 100, or add user: "0:0" to run as root. The :ro mount is never written to.
  • Use a named volume for /data (as above), not a host bind-mount. A bind-mount like ./data:/data is 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.

Environment-variable overrides honoured by the container
VariableYAML keyNotes
BRIDGE_LIBRARY_ROOTSlibraryRootsColon-separated list, e.g. /library:/library2. Add a matching read-only volume per root.
BRIDGE_LIBRARY_NAMElibraryNameDisplay name shown in the iOS app's Sources list.
BRIDGE_LISTEN_ADDRESSlistenAddressMain HTTPS bind, e.g. :7788. Change it and update the published ports: to match.
BRIDGE_UPSCALE_ENABLEDupscale.enabledtrue/false. Offline PCM upscaling + CarPlay-optimize. See Audio features.
BRIDGE_ANALYSIS_ENABLEDanalysis.enabledtrue/false. Waveform / loudness / key-tempo analysis. Same toolchain.
BRIDGE_DISABLE_HTTP3disableHttp3true bypasses the UDP listeners (also drop the /udp port).
BRIDGE_DATA_DIRdataDirPersistent volume path. Read Where state lives before overriding.
TS_AUTHKEYtailscale.authKeyHeadless tsnet auth on first boot.
TZLocal timezone for the auto-updater quiet-hours window.

The three booleans are parsed with Go's strconv.ParseBooltrue/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, so TS_AUTHKEY is 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.net in-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 7788 only 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 7788 is published and the host firewall (ufw, firewalld, Synology Security Advisor) isn't blocking it. Check the fingerprint iOS expects with docker 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 doctor and read the audio-toolchain line. The toolchain is bundled, so this almost always means the feature env var is unset or was given an unparseable value (yes/on are ignored — use true).

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.

Next

Container's up. Pair your iPhone.

The pairing flow is the same as a native install — grab the fingerprint from docker exec 1-bit-bridge bridge cert info, then follow the pairing steps.