GitHub Docker Pulls Docker Image Size License

Malachi is an open-source, 100% Elixir reimplementation of LinkedIn's NorthGuard log-storage architecture: a CP, horizontally-scalable log broker. Clients speak topics, keys, and opaque cursors, never partitions or offsets, so the broker can split and restripe its storage underneath without breaking them. The control plane is replicated by quorum (Raft via ra).

Features

  • 🚀 Append-only log on disk - CRC-checked segments, sharded into ranges the broker restripes online
  • 🔐 TLS/SSL Support - Secure connections with certificate-based auth
  • 📊 Real-time Dashboard - Built-in web UI with live metrics
  • 🔄 Consumer groups and streaming - Server-committed positions, plus server-push with credit-based flow control
  • 🌐 Multi-language Support - i18n support (en_US, pt_BR)
  • 🐳 Production Ready - Alpine-based image for minimal attack surface
  • 🏗️ Multi-Architecture - Supports AMD64 and ARM64 (Apple Silicon, AWS Graviton)

Quick Start

Pull the image

docker pull hectorcardoso/malachi:latest

Run with default settings

docker run \
  --name malachi \
  -p 127.0.0.1:4040:4040 \
  -p 127.0.0.1:4041:4041 \
  -e MALACHI_ADMIN_PASS="your_secure_password" \
  -e MALACHI_REQUIRE_TLS=false \
  hectorcardoso/malachi:latest

Two parts of that command are not decoration. The image runs the production release, and production requires TLS unless told otherwise, so without MALACHI_REQUIRE_TLS=false the container exits at once with TLS certificate file not configured. And because it is then serving plaintext, the ports are bound to 127.0.0.1: a bare -p 4040:4040 publishes on every interface your machine has, which on a shared network hands out an unencrypted broker. Change the prefix deliberately, with TLS and real credentials, rather than by deleting it. See TLS Configuration.

Note: The image automatically detects your platform (AMD64 or ARM64) and uses the appropriate build.

Access the dashboard

Open http://localhost:4041 in your browser.


Supported Platforms

ArchitectureStatusNotes
linux/amd64✅ Supportedx86_64 (Intel/AMD)
linux/arm64✅ SupportedApple Silicon (M1/M2/M3), AWS Graviton

Supported Tags

TagDescription
latestLatest stable release
X.Y.ZSpecific version (e.g., 0.2.0)
X.YLatest patch of minor version (e.g., 0.2)
XLatest minor of major version (e.g., 0)
alpineAlpine Linux base

Configuration

Environment Variables

VariableDefaultDescription
MALACHI_TCP_PORT4040TCP server port for clients
MALACHI_DASHBOARD_PORT4041HTTP dashboard port
MALACHI_LOCALEen_USLanguage (en_US, pt_BR)
MALACHI_ENABLE_TLSfalseEnable TLS encryption on the broker port. It says nothing about the dashboard, which has no TLS path.
MALACHI_DASHBOARD_SECURE_COOKIEfalseMark the dashboard session cookie Secure. Set it only behind a TLS-terminating proxy; see the ports note below.
MALACHI_ADMIN_PASS(generated)Admin password; if unset, a random one is generated and logged on first boot. Without a persistent ra volume, this happens again on every restart. See Users and credentials.
MALACHI_LOG_DATA_DIR(tmp)Directory for the durable log segments. Must be an absolute path on a volume; see Data Persistence.
MALACHI_RA_DATA_DIR(tmp)Directory for the ra log (users, ACLs, lockouts). Must be an absolute path on a volume; see Data Persistence.

Data Persistence

The broker keeps its log segments and its ra log (which holds user credentials, ACLs, and lockouts) on disk. With no volume, both default to a path under /tmp and are lost on every restart. Point the two directories at a persistent volume mounted at /app/data, which the image already owns:

docker run \
  --name malachi \
  -p 127.0.0.1:4040:4040 \
  -p 127.0.0.1:4041:4041 \
  -e MALACHI_ADMIN_PASS="your_secure_password" \
  -e MALACHI_REQUIRE_TLS=false \
  -e MALACHI_LOG_DATA_DIR=/app/data/log \
  -e MALACHI_RA_DATA_DIR=/app/data/ra \
  -v malachi-data:/app/data \
  hectorcardoso/malachi:latest

In production these must be absolute paths: a relative value is rejected at boot, because it would resolve against the working directory and put durable data back on ephemeral storage. The bundled docker-compose.yml and the Kubernetes manifest use these same paths.

TLS Configuration

docker run \
  --name malachi \
  -p 4040:4040 \
  -p 127.0.0.1:4041:4041 \
  -e MALACHI_ADMIN_PASS="your_secure_password" \
  -e MALACHI_TLS_CERTFILE=/app/priv/cert/server.crt \
  -e MALACHI_TLS_KEYFILE=/app/priv/cert/server.key \
  -v /path/to/certs:/app/priv/cert:ro \
  hectorcardoso/malachi:latest

The paths are given explicitly because there is no default for them: mounting the files somewhere the server might look is not enough, and a run that only mounts them exits at boot with TLS certificate file not configured. MALACHI_TLS_CACERTFILE is the optional third, for verifying client certificates.

The two ports are bound differently on purpose, and the asymmetry is the important part. TLS covers the broker on 4040, which is why that one is published: encrypting it is what the certificates are for. It does not cover the dashboard. Malachi.Dashboard listens with :gen_tcp.listen and has no TLS path at all, so 4041 serves plain HTTP whatever the certificates say, and it is the port carrying the login form and the session cookie. Publishing it on every interface would hand those out in cleartext next to a broker you had just taken the trouble to encrypt. Reach a remote dashboard through a reverse proxy that terminates TLS in front of it, not by widening this binding.

When you do put a proxy in front, set MALACHI_DASHBOARD_SECURE_COOKIE=true. That is the one thing the server cannot work out for itself: it only ever sees a plain-HTTP connection from the proxy, so whether the browser reached it over HTTPS is a fact about your deployment. Leave it off for a dashboard reached directly, because a browser refuses to store a Secure cookie served over plain HTTP, and the resulting login answers 200 and simply does nothing. The boot log states which of the two is in effect. Note that this used to follow MALACHI_ENABLE_TLS, which describes the broker port and made every production build mark the cookie Secure over plain HTTP; if you run behind a proxy and login worked before without setting anything, that is the setting you now need.

The mounted files are read at boot and validated then, so a certificate that is expired or unreadable stops the container rather than being discovered by a client later.


Docker Compose

version: '3.8'

services:
  malachi:
    image: hectorcardoso/malachi:latest
    container_name: malachi
    ports:
      - "4040:4040"  # TCP server
      - "4041:4041"  # Dashboard
    environment:
      - MALACHI_LOCALE=en_US
      - MALACHI_LOG_DATA_DIR=/app/data/log
      - MALACHI_RA_DATA_DIR=/app/data/ra
    volumes:
      - malachi-data:/app/data
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:4041/health"]
      interval: 30s
      timeout: 10s
      retries: 3

volumes:
  malachi-data:

These compose defaults are fine for test and development; tune them conservatively for production.

Two ready-made stacks live in the repository, and both go further than the snippet above.

Single node, observable

docker compose up -d

A broker plus Jaeger and Prometheus, so the thing you just started is one you can watch:

WhatWhere
Broker (binary protocol)localhost:4040
Dashboardhttp://localhost:4041
Traceshttp://localhost:16686
Metricshttp://localhost:9090

Produce a little traffic and a produce shows up in Jaeger as a distributed trace: malachi.produce at the root, malachi.broker.produce under it, and malachi.replication.commit under that, carrying the topic, the record count and the byte count. Tracing is off by default everywhere else, because the sampler dropping every span is what makes Tracer.with_span free on the produce path; this stack sets MALACHI_TRACING_ENABLED=true and samples everything, which is right for watching a trickle and wrong for a busy node (use MALACHI_TRACING_SAMPLE_RATIO there).

The metrics side needs one moving part that is worth understanding before you copy it. /metrics requires an authenticated user and a Malachi session expires, so a token pasted into a scrape config works right up until it does not. A small metrics-token sidecar logs in and rewrites the file Prometheus reads through credentials_file, which Prometheus re-reads on every scrape. It shares Prometheus's network namespace on purpose: sessions are bound to the IP that created them (MALACHI_SESSION_IP_BINDING), so the token has to be minted from the address that will use it.

Durable cluster, observable

docker compose -f docker-compose.cluster-durable.yml up -d

Three brokers forming one replicated control plane at replication factor 3, each on its own named volume, plus the same Jaeger and Prometheus. Restart the whole thing and the data is still there, which is the difference between this and docker-compose.cluster.yml: that one exists for benchmarks and keeps its data on tmpfs so fsync cost stays uniform across runs, and tmpfs is remounted empty on every restart.

Only node 1 publishes to the host, so it is the only one a client on your machine can reach. The three are interchangeable to a client on the compose network, which is the sense that matters for how the cluster behaves: produce through any of them and the write is quorum-replicated across all three. Publish the other two if you want to exercise that from outside. Prometheus scrapes all three, because most Malachi series are per-node facts (BEAM memory, connections, the integrity scrub's progress over that node's disk) and scraping one would report a third of the cluster while looking complete. It uses one scrape job per node, each with its own token: users, ACLs and lockouts are replicated across the cluster through the auth ra group, but sessions are not, so a session minted on node 1 is rejected by node 2.

Both stacks ship with placeholder credentials in a public file, with MALACHI_REQUIRE_TLS=false, and with Jaeger and Prometheus carrying no authentication of their own. Every port they publish is bound to 127.0.0.1 because of that combination rather than in spite of it: on 0.0.0.0 these files would hand anyone who can route to the host a plaintext broker whose password is on GitHub, plus every trace it has recorded.

Undoing that takes three separate things, and the middle one is easy to miss:

MALACHI_ADMIN_PASS="$(openssl rand -base64 32)" \
MALACHI_REQUIRE_TLS=true \
MALACHI_TLS_CERTFILE=/certs/server.pem \
MALACHI_TLS_KEYFILE=/certs/server-key.pem \
  docker compose up -d

Real passwords in the environment. MALACHI_REQUIRE_TLS=true, which is not implied by setting the certificate paths: with the flag left at false the broker reads those files and still serves plaintext, a security setting failing in the shape that looks configured. And certificates mounted into the container, because those two paths resolve inside it, so add a - /etc/malachi/certs:/certs:ro volume alongside them.

Only then widen the broker's bindings, and widen only the broker's. Jaeger and Prometheus have no authentication to turn on, so they stay on 127.0.0.1 until you have put something in front of them that does.


Client protocol

Malachi speaks a length-framed binary protocol on the TCP port: a connection authenticates first, then exchanges topic, produce, fetch, commit, and subscribe frames. Positions are opaque cursors, never offsets. Clients do not hand-write frames.

Use one of the supported clients instead:

  • the reference Node CLI in scripts/ (producer.js, consumer.js, subscriber.js), which reads MALACHI_HOST, MALACHI_PORT, MALACHI_USER, MALACHI_PASS, MALACHI_TOPIC;
  • the in-process Elixir API (Malachi.LogApi) for embedded use.

See the guides for producing, consuming, consumer groups, and streaming with backpressure.


Users and credentials

The image runs in production mode, which ships no hardcoded passwords. On first boot, when MALACHI_ADMIN_PASS is not set, the broker generates a random admin password and logs it once, so read the container logs to retrieve it. The generated user lives in the ra store, so without a persistent MALACHI_RA_DATA_DIR volume (see Data Persistence) the admin user is gone on the next restart and a new random password is generated and logged again. Set credentials explicitly with either:

  • per-user env vars: MALACHI_ADMIN_PASS, MALACHI_PRODUCER_PASS, MALACHI_CONSUMER_PASS, MALACHI_APP_PASS. A user is seeded only when its password is set; producer gets produce, consumer gets consume, and app gets both.
  • or a full list via MALACHI_DEFAULT_USERS in the form user:pass:perm,perm;user2:....

Set MALACHI_DISABLE_DEFAULT_USERS=true to seed no users and manage them entirely through the API.

⚠️ The admin123 / producer123 / consumer123 credentials exist only in the dev and test configs, never in this production image.


Health Check

The dashboard exposes two public probe endpoints (no authentication, so a probe never sends credentials):

  • /health - liveness: returns 200 {"status":"ok"} once the HTTP server is up.
  • /ready - readiness: returns 200 {"status":"ready"} when the log broker is running, else 503.
wget -q -O - http://localhost:4041/health

The bundled image health check probes /health with wget; /metrics is authenticated and not a health endpoint.


Image Details

PropertyValue
Base Imagealpine:3.21
RuntimeErlang/OTP 28, Elixir 1.19
Architecturelinux/amd64, linux/arm64
Usermalachi (UID 1000)
Workdir/app
JIT CompilationEnabled (+JPperf true)
Runtime Dependenciesopenssl, libstdc++, ncurses-libs

Testing & Validation

The Docker image includes comprehensive testing scripts:

Build Validation

# Validates runtime dependencies, JIT configuration, and performance benchmarks
make docker-validate

Checks:

  • ✅ Runtime dependencies (argon2 NIF, openssl)
  • ✅ ERL_FLAGS configuration with JIT
  • ✅ Service availability (TCP + Dashboard)
  • ✅ Performance benchmarks with throughput metrics
  • ✅ Memory usage validation

Regression Testing

# Runs comprehensive regression tests
make docker-regression-test

Tests include:

  • HTTP/SSE endpoints functionality
  • TCP server availability
  • Queue publish/consume workflows
  • High-volume message throughput (10K+ messages)
  • Memory stability under load
  • Concurrent multi-queue operations
  • JIT compilation verification

Full Test Suite

# Build + Validate + Regression tests
make docker-test-all

Source Code


License

MIT License - see LICENSE for details.