Meelo: four compose services, a blank example .env, and setup that lives in the wiki
Self-hosted Music Server. Focused on metadata integration & UI
At a glance
- What is it?
- Meelo is a self-hosted music server for collectors, built as four compose services behind an nginx template. The checked-in configuration is the informative part, and it shows a stack that builds from source, gates startup on health checks, and ships every credential empty.
- Who is it for?
- Meelo fits a collector who will tag the library properly and who is willing to read a wiki before the first run. The checked-in configuration is the honest part of this project, and it also shows the sharp edges: the compose stack builds from source with VERSION=local while the example environment file asks for a pinned tag, every secret in that example file is blank, and account creation is one value away from locking you out of your own instance.
- Can I use it commercially?
- Yes, with conditions. GPL-3.0 is a copyleft licence: if you distribute software that includes it, you must release that software's source code under the same licence. Running it internally without distributing it does not trigger that obligation.
- Is it still maintained?
- Yes. The repository last received commits 5 days ago.
- What is it written in?
- Mainly TypeScript, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 2, 2026, and from our analysis. They are not legal advice.
Editorial analysis
The compose stack builds from source and never reads the tag you pinned
The example environment file asks for a pinned image, and the compose file next to it ignores that request. The file recommends setting `TAG` to a value such as `vX.X.X`, links the release changelog, and warns that the `edge` tag follows the main branch and exposes you to unwarranted breaking changes. What the visible compose actually does is build every service from the repository itself:
server:
init: true
build:
context: ./server
args:
- VERSION=local
expose:
- 4000
restart: on-failure
depends_on:
db:
condition: service_healthy
meilisearch:
condition: service_healthy
mq:
condition: service_healthy
volumes:
- ${DATA_DIR}:${INTERNAL_DATA_DIR}
- ${CONFIG_DIR}:${INTERNAL_CONFIG_DIR}
env_file:
- .env
environment:
- TRANSCODER_URL=http://transcoder:7666
- MEILI_HOST=http://meilisearch:7700
- RABBITMQ_URL=amqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@mq:5672
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public
healthcheck:
test: ["CMD-SHELL", "wget -qO- localhost:4000"]
interval: 3s
timeout: 5s
retries: 20The build argument is `VERSION=local` in all three contexts, `./server`, `./front` and `./scanner`, so the tag advice describes a different deployment path than the one in this file. The opening comment calls the file a production like environment, and the root listing carries `docker-compose.yml`, `docker-compose.dev.yml` and `docker-compose.prod.yml` side by side, so three files claim the same job with different contents. The header comment also says the whole project is built, which matches the build arguments but not the pinned image workflow.
Startup is gated on a wget that polls every 3 seconds and waits 5
Nothing starts until something answers a health probe, and the probe is a plain HTTP fetch:
scanner:
build:
context: ./scanner
args:
- VERSION=local
expose:
- 8133
depends_on:
server:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- localhost:8133"]
interval: 3s
timeout: 5s
retries: 20
environment:
- API_URL=http://server:4000The same three values appear on the server at port 4000, so every check is a wget of a loopback URL, which means each image has to carry wget. Two details are worth reading twice. The timeout of 5s is longer than the 3s interval, so a slow response overlaps the next tick rather than being cut short. And 20 retries at that interval gives roughly a minute before a service is written off as unhealthy, which is the window the rest of the stack waits on: the server waits for the database, Meilisearch and the message queue, the scanner waits for the server, and the front waits for both the server and the scanner.
The asymmetry is deliberate enough to notice. The server declares `init: true` and `restart: on-failure`, while the front declares neither a health check nor a restart policy, so once the front is up nothing re-checks it.
The front receives every backend twice, once public and once internal
Three backends, two addresses each, six variables:
front:
build:
context: ./front
args:
- VERSION=local
expose:
- 3000
depends_on:
server:
condition: service_healthy
scanner:
condition: service_healthy
environment:
- PUBLIC_SERVER_URL=${PUBLIC_URL}/api
- SSR_SERVER_URL=http://server:4000
- PUBLIC_SCANNER_URL=${PUBLIC_URL}/scanner
- SSR_SCANNER_URL=http://scanner:8133
- PUBLIC_MATCHER_URL=${PUBLIC_URL}/matcher
- SSR_MATCHER_URL=http://matcher:6789The public half is built by concatenation: `${PUBLIC_URL}` plus `/api`, `/scanner` and `/matcher`. That is why the example environment file asks for the public URL without a trailing slash, since a trailing slash here would produce a double slash in every browser facing URL. The internal half uses service names and container ports that match the `expose` lists elsewhere in the file, which means server side rendering never leaves the compose network while the browser goes out through whatever publishes the ports.
One reference is dangling in the visible part. The server points `TRANSCODER_URL` at `http://transcoder:7666`, and the front points at `matcher:6789`, but no transcoder or matcher service definition appears in the portion of the file shown here. Both are referenced before they are defined, so a reader has to check the rest of the file or the wiki before trusting the pair.
Every value in the example environment file is blank except one
The `.env.example` is a form with almost every field left empty on purpose, and one field not:
#################### Security
# Random String used to sign JWT Tokens
JWT_SIGNATURE=
# Key used to authenticate the Meilisearch Instance
# Should be a random strin`TAG`, `PORT`, `PUBLIC_URL`, `CONFIG_DIR`, `DATA_DIR`, `ALLOW_ANONYMOUS`, `JWT_SIGNATURE`, the three Postgres values, the two RabbitMQ values and both Last.fm values are all blank. `ENABLE_USER_REGISTRATION=1` is the only prefilled line. `CONFIG_DIR` is described as the directory holding `settings.json` and the stored illustrations on the host machine, and `DATA_DIR` as the root path of the libraries.
That matters because the compose file interpolates these blanks into connection strings: `DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public` puts whatever is in the file straight into the URL, and the same holds for the RabbitMQ address. The JWT signature carries no default and no note about what an unset value means. The visible text of the example file stops inside the Security block, on the comment about the Meilisearch key, which is where the misspelled `strin` sits. The rest of the checkout is tool driven: `shell.nix`, `biome.json`, `renovate.json`, `CONTRIBUTING.md`, a `LICENSE` and `settings.json` at the root.
Anonymous reads and account creation are two separate switches
Two lines in the example file decide who can reach the instance and who can join it, and they are not the same decision.
`ALLOW_ANONYMOUS` takes a 1 to allow anonymous requests, with a comment noting that this will not affect front end behaviour. `ENABLE_USER_REGISTRATION` defaults to 1, and setting it to 0 stops users from creating accounts. The warning attached to that value is the sharp edge: do not set it to 0 if you have not created the first admin account yet.
That is a one value path to an instance you cannot log into, with the recovery left to editing the file and restarting rather than to anything in the interface. The asymmetry is worth holding onto. Anonymous access can be granted and revoked at will, and it changes nothing about how the front end behaves, while registration is the switch that can lock the owner out. Neither value has a stated relationship to the JWT signature or to the ports, so the two should be treated as separate decisions rather than as one privacy dial.
Metadata can come from tags, from file names, or from both
The metadata model is the reason the project says it is built for collectors rather than for a general media library, and it is where the setup burden lands.
Sources can be the embedded tags, the file name, or both, and the same parser handles album covers. On top of that sits a relationship model: albums have releases, so one album can have several versions with only the main one appearing on browsing pages; songs have tracks, which is what keeps a library from listing the same song twice; songs also have versions, with an example image for song groups; and album and song types cover instrumental songs and live recordings. B sides and rare tracks are detected and surfaced on the album and artist pages, and there is a filter for songs exclusive to an album, limited to compilation albums. Featuring and duet detection is automatic.
Format support is described as a consequence of two mechanisms rather than a list of codecs: the way files are parsed, and transcoding. Transcoding only runs when the browser cannot play the stored format. The page is direct about the consequence for the operator, telling you to make sure your music is correctly tagged before starting, and warning that if you want file paths used as a metadata source you will need to be familiar with regular expressions.
Text and ratings come from outside, and only one scrobbler needs a key
Genres, descriptions and ratings are not stored by Meelo. They are fetched from MusicBrainz, Genius, Wikipedia and other providers named in the feature list, which means the text you see for an album depends on external services answering for that artist.
Lyrics arrive through three routes: downloaded and synced, read from embedded metadata, or read from `.lrc` files sitting next to the audio. Those are three separate sources to keep consistent, and only the first two depend on tags being right.
Scrobbling splits into two halves, and the example environment file is explicit about the difference. Every scrobbler value is optional, and no configuration is needed for ListenBrainz. Last.fm needs an API account, with `LASTFM_API_KEY` and `LASTFM_API_SECRET` filled in from the account page the file links to. The feature list describes both destinations as plain push targets, so the asymmetry sits entirely in the credentials.
The practical consequence is that a mislabelled file can look empty rather than wrong. If the tags do not resolve to a provider match, the metadata that would have filled in the description and the ratings has nothing to attach to, and the album page shows the gaps instead of an error.
The setup step is a wiki link, and the demo has not shipped yet
The setup section is three sentences. Meelo is shipped through Docker images, the word used in the page being though rather than through. Getting started means following the dedicated wiki, and the page adds that you should tag your music properly first. No compose command, no image name and no environment walkthrough appear in the README itself, so the wiki carries the actual first run.
The rest of the document is honest about its own state. A live demo section says a public demo is being worked on and asks readers to wait, so there is nothing to try yet. The screenshots note warns that some may be outdated and may not show the current version of the app, and points at `./docs/screenshots/` for more. Translations run through Weblate on an en-gb locale. The mobile section says the stable Android APK is on the release page, that iOS is distributed through TestFlight, and that APKs and IPAs are built whenever code is pushed to the main branch.
The page carries visible defects along the way. The mobile build link is labelled this worklow, the closing line asks you to make the most out of you music collection, the SonarCloud badge is printed twice with the same project id, an empty flex div opens the file, and a `</details>` closes nothing after the screenshots note. None of that changes the software, and all of it is the first thing a visitor reads.
Editorial conclusion
Meelo fits a collector who will tag the library properly and who is willing to read a wiki before the first run. The checked-in configuration is the honest part of this project, and it also shows the sharp edges: the compose stack builds from source with VERSION=local while the example environment file asks for a pinned tag, every secret in that example file is blank, and account creation is one value away from locking you out of your own instance. Confirm the health gating and the transcoder service definition in the wiki before trusting an unattended deploy, and keep the instance private the way the disclaimer requires.
Frequently asked questions
Is Meelo the animated character or the music server?
In this repository Meelo is a self-hosted music server written in TypeScript under GPL-3.0, described as working similarly to Plex, Jellyfin, Koel and Black Candy. Searches about a character of that name land on the wrong project entirely.
How do I set up Meelo?
The setup section is a link to the project wiki rather than a walkthrough, and it adds two conditions: tag your music with embedded metadata before you start, and expect to need regular expressions if file paths are to be used as a metadata source. Builds are shipped as Docker images.
Does Meelo need credentials to scrobble to Last.fm?
Yes, for Last.fm. The example environment file states that all scrobbler values are optional and that no configuration is needed for ListenBrainz, while Last.fm takes LASTFM_API_KEY and LASTFM_API_SECRET from an API account.
What services and ports does Meelo run?
The compose file defines server on 4000, front on 3000 and scanner on 8133, and the front receives internal addresses for the server, the scanner and a matcher on 6789. The server also points at Postgres on 5432, Meilisearch on 7700, RabbitMQ on 5672 and a transcoder on 7666, and nginx.conf.template sits at the root of the repository.
Does Meelo have a mobile app for iOS?
The mobile section says the iOS build is distributed through TestFlight, that the latest stable Android APK is on the release page, and that APKs and IPAs are also built whenever code is pushed to the main branch.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/arthi-chaud-meelo)