Meelo logo

Meelo

Self-hosted music server for collectors, focused on metadata and browsing

Alternative to: spotify, apple music

Meelo screenshot

Meelo is a self-hosted music server built for music collectors, similar in spirit to Plex or Jellyfin but focused on rich browsing and listening experiences. It handles music videos, B-sides, and multiple releases of the same album, and enriches your library with metadata pulled from sources like MusicBrainz, Genius, and Wikipedia. It ships as Docker images with companion mobile apps for Android and iOS.

Meelo Docker Compose example

Self-host Meelo on your own server, homelab, or VPS starting from this Docker Compose example. It runs Meelo in Docker containers using the official arthichaud/meelo-server:latest, arthichaud/meelo-scanner:latest, arthichaud/meelo-matcher:latest, arthichaud/meelo-front:latest, ghcr.io/zoriya/kyoo_transcoder:master, postgres:alpine3.14, getmeili/meilisearch:v1.5, rabbitmq:4.3-alpine, nginx:alpine images, with persistent volumes and automatic restarts preconfigured. Review the environment variables and adjust them to your setup, save the file as compose.yml (or docker-compose.yml), and start the stack with docker compose up -d.

services:
  server:
    init: true
    image: arthichaud/meelo-server:latest
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
      meilisearch:
        condition: service_healthy
      mq:
        condition: service_healthy
    volumes:
      - data:/data
      - config:/config
      - ./settings.json:/config/settings.json:ro
    environment:
      TRANSCODER_URL: "http://transcoder:7666"
      MEILI_HOST: "http://meilisearch:7700"
      # Master key used to authenticate against the internal Meilisearch instance (must match the meilisearch service).
      MEILI_MASTER_KEY: "meelo_meili_master_key_change_me"
      RABBITMQ_URL: "amqp://meelo:meelo@mq:5672"
      DATABASE_URL: "postgresql://meelo:meelo@db:5432/meelo?schema=public"
      # Random string used to sign JWT session tokens.
      JWT_SIGNATURE: "changeme"
      # Comma-separated random strings used for internal service-to-service authentication.
      API_KEYS: "changeme"
      # Public URL to access Meelo, without trailing slash (e.g. https://meelo.example.org).
      PUBLIC_URL: "http://localhost:5000"
      # Set to 1 to allow anonymous (unauthenticated) API requests.
      ALLOW_ANONYMOUS: "0"
      # Set to 0 to disable account creation (do not disable before the first admin account exists).
      ENABLE_USER_REGISTRATION: "1"
      # Optional Last.fm scrobbler API key (https://www.last.fm/api/account/create).
      LASTFM_API_KEY: ""
      # Optional Last.fm scrobbler API secret.
      LASTFM_API_SECRET: ""
      INTERNAL_DATA_DIR: "/data"
      INTERNAL_CONFIG_DIR: "/config"
    healthcheck:
      test: ["CMD-SHELL", "wget -qO- localhost:4000"]
      interval: 3s
      timeout: 5s
      retries: 20

  scanner:
    image: arthichaud/meelo-scanner:latest
    restart: unless-stopped
    depends_on:
      server:
        condition: service_healthy
    environment:
      API_URL: "http://server:4000"
      INTERNAL_CONFIG_DIR: "/config"
      INTERNAL_DATA_DIR: "/data"
      # Comma-separated random strings used for internal service-to-service authentication.
      API_KEYS: "changeme"
    volumes:
      - data:/data:ro
      - config:/config:ro
      - ./settings.json:/config/settings.json:ro
    healthcheck:
      test: ["CMD-SHELL", "wget -qO- localhost:8133"]
      interval: 3s
      timeout: 5s
      retries: 20

  matcher:
    image: arthichaud/meelo-matcher:latest
    restart: unless-stopped
    depends_on:
      server:
        condition: service_healthy
      mq:
        condition: service_healthy
    environment:
      API_URL: "http://server:4000"
      INTERNAL_CONFIG_DIR: "/config"
      # Comma-separated random strings used for internal service-to-service authentication.
      API_KEYS: "changeme"
      RABBITMQ_URL: "amqp://meelo:meelo@mq:5672"
    volumes:
      - config:/config:ro
      - ./settings.json:/config/settings.json:ro

  front:
    image: arthichaud/meelo-front:latest
    restart: unless-stopped
    depends_on:
      server:
        condition: service_healthy
      scanner:
        condition: service_healthy
    environment:
      # Browser-facing API URL. Path-routed through the bundled nginx to the server service.
      PUBLIC_SERVER_URL: "http://localhost:5000/api"
      SSR_SERVER_URL: "http://server:4000"
      # Browser-facing scanner URL. Path-routed through the bundled nginx to the scanner service.
      PUBLIC_SCANNER_URL: "http://localhost:5000/scanner"
      SSR_SCANNER_URL: "http://scanner:8133"
      # Browser-facing matcher URL. Path-routed through the bundled nginx to the matcher service.
      PUBLIC_MATCHER_URL: "http://localhost:5000/matcher"
      SSR_MATCHER_URL: "http://matcher:6789"

  transcoder:
    image: ghcr.io/zoriya/kyoo_transcoder:master
    restart: unless-stopped
    cpus: 1
    depends_on:
      db:
        condition: service_healthy
    environment:
      GOCODER_SAFE_PATH: "/data"
      PGUSER: "meelo"
      PGPASSWORD: "meelo"
      PGDATABASE: "meelo"
      PGHOST: "db"
      PGPORT: "5432"
    volumes:
      - data:/data:ro
      - transcoder_cache:/cache

  db:
    image: postgres:alpine3.14
    restart: unless-stopped
    environment:
      POSTGRES_USER: "meelo"
      # Internal-only database password; not reachable outside the compose network.
      POSTGRES_PASSWORD: "meelo"
      POSTGRES_DB: "meelo"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U meelo -d meelo"]
      interval: 3s
      timeout: 3s
      retries: 20
    volumes:
      - db:/var/lib/postgresql/data

  meilisearch:
    image: getmeili/meilisearch:v1.5
    restart: unless-stopped
    environment:
      MEILI_ENV: "production"
      # Master key securing the internal Meilisearch instance (must match the server service).
      MEILI_MASTER_KEY: "meelo_meili_master_key_change_me"
      MEILI_LOG_LEVEL: "WARN"
    volumes:
      - search:/meili_data
    healthcheck:
      test: ["CMD", "wget", "--no-verbose", "--spider", "http://0.0.0.0:7700/health"]
      interval: 3s
      timeout: 3s
      retries: 20

  mq:
    image: rabbitmq:4.3-alpine
    hostname: meelo-mq
    restart: unless-stopped
    environment:
      RABBITMQ_DEFAULT_USER: "meelo"
      # Internal-only message-queue password; not reachable outside the compose network.
      RABBITMQ_DEFAULT_PASS: "meelo"
    healthcheck:
      test: rabbitmq-diagnostics -q ping
      interval: 5s
      timeout: 5s
      retries: 20
    volumes:
      - rabbitmq_data:/var/lib/rabbitmq

  # Bundled nginx: the single entry point. Path-routes / to the front UI, /api to
  # the server, /scanner to the scanner and /matcher to the matcher. Its server
  # block is rendered from nginx.conf.template by the nginx image's envsubst
  # entrypoint using the FRONT_URL/SERVER_URL/SCANNER_URL/MATCHER_URL/PORT vars.
  nginx:
    image: nginx:alpine
    restart: unless-stopped
    depends_on:
      server:
        condition: service_started
      front:
        condition: service_started
    environment:
      PORT: "5000"
      FRONT_URL: "http://front:3000"
      SERVER_URL: "http://server:4000"
      SCANNER_URL: "http://scanner:8133"
      MATCHER_URL: "http://matcher:6789"
    volumes:
      - ./nginx.conf.template:/etc/nginx/templates/meelo.conf.template:ro

volumes:
  db:
  config:
  data:
  search:
  transcoder_cache:
  rabbitmq_data:

Values set to changeme are required — replace them with your own values before starting Meelo.

Extra files

The Docker Compose configuration above bind-mounts the following files. Save them next to your compose.yml, keeping the same relative paths.

nginx.conf.template

server {
	listen ${PORT} default_server;
	listen [::]:${PORT} default_server;
	access_log off;
	server_name _;

	location = /api {
	    return 302 /api/;
	}
	location /api/ {
	    proxy_pass ${SERVER_URL}/;
	}
	location = /scanner {
	    return 302 /scanner/;
	}
	location /scanner/ {
	    proxy_pass ${SCANNER_URL}/;
	}
	location = /matcher {
	    return 302 /matcher/;
	}
	location /matcher/ {
	    proxy_pass ${MATCHER_URL}/;
	}
	location / {
		proxy_pass ${FRONT_URL};
	}
}

settings.json

{
	"trackRegex": [
		"^([\\/\\\\]+.*)*[\\/\\\\]+(?P<AlbumArtist>.+)[\\/\\\\]+Unknown Album[\\/\\\\]+(?P<Track>.*)\\..*$",
		"^([\\/\\\\]+.*)*[\\/\\\\]+(?P<AlbumArtist>.+)[\\/\\\\]+(?P<Album>.+)(\\s+\\((?P<Year>\\d{4})\\))?[\\/\\\\]+((?P<Disc>[0-9]+)-)?(?P<Index>[0-9]+)\\s+(?P<Track>.*)\\..*$"
	],
	"metadata": {
		"source": "embedded",
		"order": "only",
		"useExternalProviderGenres": true
	},
	"providers": {
		"musicbrainz": {}
	},
	"compilations": {
		"useID3CompTag": true,
		"artists": [
			"Various Artists"
		]
	},
	"useEmbeddedThumbnails": false
}

Prefer a managed setup? WinterFlow installs, configures, and updates Meelo for you using this same Docker Compose configuration.