# lrclib-rs: the Rust rewrite behind lrclib.net runs on one SQLite file

> The Rust and Axum rewrite of the lrclib.net synchronized lyrics service, stored in a single SQLite3 file and tuned through three LRCLIB_ environment variables. Solid to self-host, thin on the endpoints and schema an adopter would need to confirm.

**tranxuanthang/lrclib** — LRCLIB server written in Rust with Axum and SQLite3 database

- Repository: https://github.com/tranxuanthang/lrclib
- Website: https://lrclib.net
- Stars: 2,104 · Forks: 86
- Language: Rust
- License: MIT
- Published: 2026-10-14 · Updated: 2026-10-14 · Language: en
- Canonical page: https://hysenlabs.com/projects/tranxuanthang-lrclib

## The lyrics lookup problem Axum and one SQLite file solve

lrclib.net is a synchronized lyrics service. Instead of a wall of unsynced text, it stores lyric lines with timestamps so a player can highlight the line being sung. This repository is the server behind that site, written in Rust from scratch, and it serves both human visitors and programs through machine-friendly APIs.

The job it does is narrow. A music player, a karaoke app or a lyrics bot needs the same three answers: find a recording by artist and title, confirm its duration, and return timed lines. One SQLite file holds all of it and Axum puts it on a single HTTP port. The declared topics on the repository are axum, lyrics, music, rust, sqlite and synced-lyrics, which matches that scope exactly.

One gap is worth naming up front. The rewrite is described as being done from scratch, and the text never names the implementation it replaced, so no comparison of old and new behaviour is published. What a reader gets instead is a service whose storage choice is unusually easy to reason about, and whose operation depends on a handful of documented environment variables.

## Running the server from source with two shell commands

Compilation is a plain Cargo build, and the binary takes a `serve` subcommand plus a database path. Both commands belong in the same shell session, because the second one runs the server in the foreground and holds the terminal while it serves.

```bash
cargo build --release
LRCLIB_LOG=info cargo run --release -- serve --database db.sqlite3
```

`LRCLIB_LOG=info` sets the log level for the process. The trailing `--database db.sqlite3` opens, or creates, a SQLite file relative to the working directory you launch from, and nothing in the documented setup moves it to a fixed location afterwards. Start the service from a different directory under systemd or Quadlet and you silently get a different, empty database.

The documented endpoint is http://0.0.0.0:3300, yet this run line passes no port flag, so the binary's built-in default port is written down nowhere in these files. The container image closes that gap for itself by always passing the port explicitly. Expect a listening server on 3300 and a `db.sqlite3` file next to the directory where you ran the command. The file starts empty, so early queries return nothing until lyrics are contributed.

## Three environment variables size the SQLite connection

Three `LRCLIB_`-prefixed variables tune the database layer, and there is no config file to edit. `LRCLIB_MMAP_SIZE` defaults to `30000000000`, a 30GB memory-mapped region, and the guidance is to keep it below 75% of available RAM because an excessive value pushes the system into swapping and costs performance. `LRCLIB_CACHE_SIZE` defaults to `-1000000`, and the negative sign is meaningful: SQLite reads negative cache sizes as kilobytes, which is how that default becomes 1GB of page cache.

`LRCLIB_POOL_MAX_SIZE` defaults to 16 and caps the number of SQLite connections the r2d2 pool manages. It must be a positive integer, so a value of zero is a configuration error rather than a way to disable pooling.

Those defaults are sized for a production machine. On a small host or a low traffic instance, these three variables are the only lever the project offers: shrink `LRCLIB_MMAP_SIZE` first, keep `LRCLIB_CACHE_SIZE` expressed in kilobytes if you prefer readable numbers, and lower `LRCLIB_POOL_MAX_SIZE` to hold fewer connections open. The documentation describes no way to change any of them while the process runs, so each adjustment takes effect on the next start.

## The image lifts the memory ceiling the tuning notes set

Set the Dockerfile against the memory advice and a contradiction appears. Operators are told to hold `LRCLIB_MMAP_SIZE` under 75% of RAM, then the builder stage compiles the bundled SQLite with a ceiling written in hexadecimal:

```dockerfile
FROM docker.io/rust:1.91-alpine3.22 as builder
ENV CFLAGS="-DSQLITE_MAX_MMAP_SIZE=0x4000000000"
```

That cap sits far above the `30000000000` default the project publishes. The image therefore leaves headroom for a mapping size the documentation itself calls excessive, which makes the limit advisory rather than enforced by the binary. Following the tuning advice strictly never reaches it. Raising the variable on a large host does, and the named failure mode there is swapping, not a crash.

Two smaller facts live only in the Dockerfile. The runtime stage installs a SQLite client, which is what makes the documented command for reading the database from inside the container possible at all, since the server binary ships no such tool. The entrypoint also pins the port:

```dockerfile
FROM alpine:3.22
RUN apk add --no-cache sqlite
EXPOSE 3300
CMD ["lrclib", "serve", "--port", "3300", "--database", "/data/db.sqlite3"]
```

Note the absolute path, `/data/db.sqlite3`, against the relative `db.sqlite3` used for a local run.

## A named volume is what carries lyrics past a container swap

Container deployment is documented for Podman with a locally built image:

```bash
podman build -t lrclib-rs:latest -f Dockerfile .
```

The run line creates a named volume, maps the port, and detaches:

```bash
podman run --rm -it -d -v lrclib-data:/data -p 3300:3300 -e LRCLIB_LOG=info --name lrclib-rs lrclib-rs:latest
```

`-v lrclib-data:/data` carries the real weight. Because the entrypoint writes to `/data/db.sqlite3`, contributed lyrics survive replacing the container only while that volume exists, and the inspection command below depends on the identical volume name. Rename one and forget the other and you open an empty file with a `sqlite3` prompt:

```bash
podman run --rm -it -v lrclib-data:/data lrclib-rs:latest sqlite3 /data/db.sqlite3
```

For a host that comes back on its own, the documented route is a Quadlet unit. Create `~/.config/containers/systemd/`, put a file named `lrclib.container` inside it, and mirror the run flags:

```ini
[Container]
Image=lrclib-rs:latest
PublishPort=3300:3300
Volume=lrclib-data:/data
ContainerName=lrclib-rs
Environment=LRCLIB_LOG=info
[Service]
Restart=always
[Install]
WantedBy=multi-user.target default.target
```

Four `systemctl --user` calls complete it: reload the daemon, start `lrclib.service`, check its status, and restart it after rebuilding the image.

## A two-crate workspace the setup notes never describe

The manifest is a two-crate workspace, and the setup section never mentions that split:

```toml
[package]
name = "lrclib"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = "1"
server = { path = "./server" }
clap = { version = "4.5.4", features = ["derive", "env"] }
tokio = { version = "1.37.0", features = ["full"] }
[workspace]
members = ["server"]
```

The root package is the `lrclib` binary. `server` is both a path dependency and the only workspace member, and the top-level tree carries `src/` and `server/` side by side, so the HTTP layer sits apart from the entry point while no prose explains which directory holds what. The `clap` features line is the visible reason every control takes the shape of a subcommand plus an `LRCLIB_`-prefixed variable, and `tokio` at `1.37.0` with `full` supplies the async runtime an Axum server expects.

Reproducibility also differs between the two paths. The image pins `rust:1.91-alpine3.22` and compiles the whole workspace with `--workspace`, while a local build gets no toolchain instruction at all, since no toolchain file appears in the tree. `Cargo.lock` is committed, which pins dependency versions but not the compiler that reads them.

## What the published repository leaves unanswered

Several questions an adopter asks first have no answer in the published files. The HTTP surface is the largest gap: no endpoint paths, no request or response shapes, no status codes and no authentication scheme appear anywhere, while the service is advertised as machine-friendly. The lyrics schema is equally absent, so nothing here shows how a recording, its artist and its timed lines relate inside the tables.

The running surface has its own hole. The container run publishes port 3300 to the host, which binds on every interface, and no TLS termination, rate limiting or access control is described in front of it. An exposed instance stays an open read endpoint until a proxy of your own sits in front. A schema change on an existing `db.sqlite3` file has no documented procedure, there is no rollback path, and no test suite is described even though a `.github/` directory sits at the top level. `ARCHITECTURE.md` and `AGENTS.md` are both listed in the tree, and no text from either accompanies the manifest and Dockerfile, so the internal design stays outside what a reader can check.

## Conclusion

Adopt this when you want to self-host the lyrics backend behind lrclib.net and are willing to own a Rust service, a SQLite file and a container runtime. Pass on it if you need documented endpoints, an inspectable schema or any authentication story, because the published files supply none of those three. Verify first where the server binds and what it writes: the documented run line passes no port flag at all and stores a relative db.sqlite3, while the image always passes `--port` 3300 with `/data/db.sqlite3` inside the `lrclib-data` volume.

## FAQ

### How do I run the lrclib Rust server locally?

Run `cargo build --release`, then `LRCLIB_LOG=info cargo run --release -- serve --database db.sqlite3`. The server then answers at http://0.0.0.0:3300 with a SQLite file created relative to your working directory.

### Which environment variables tune the lrclib SQLite database?

`LRCLIB_MMAP_SIZE` defaults to `30000000000` and should stay under 75% of available RAM, `LRCLIB_CACHE_SIZE` defaults to `-1000000` and is read as kilobytes because it is negative, and `LRCLIB_POOL_MAX_SIZE` defaults to 16 r2d2 connections and must be a positive integer.

### How do I run lrclib in Docker or Podman and keep its data?

Build with `podman build -t lrclib-rs:latest -f Dockerfile .` and run with `podman run --rm -it -d -v lrclib-data:/data -p 3300:3300 -e LRCLIB_LOG=info --name lrclib-rs lrclib-rs:latest`. Data lives in the `lrclib-data` volume, because the container writes `/data/db.sqlite3`.

### Can I inspect the lrclib lyrics database from the command line?

Yes, the project ships a container recipe for it: `podman run --rm -it -v lrclib-data:/data lrclib-rs:latest sqlite3 /data/db.sqlite3`. This works because the runtime stage installs a SQLite client on top of the server binary.

### Does the lrclib documentation list its API endpoints or database schema?

No. No endpoint paths, request or response shapes, status codes, authentication scheme or table definitions are published, so anyone integrating against a self-hosted instance has to probe the running server or read `ARCHITECTURE.md` directly.

## Sources

- [Issues](https://github.com/tranxuanthang/lrclib/issues)
- [License: MIT](https://github.com/tranxuanthang/lrclib/blob/main/LICENSE)
- [Project website](https://lrclib.net)
- [README](https://github.com/tranxuanthang/lrclib/blob/main/README.md)
- [tranxuanthang/lrclib on GitHub](https://github.com/tranxuanthang/lrclib)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/tranxuanthang-lrclib
