The registry that knows what you deployed.
Mantle is a self-hostable OCI Distribution registry that links every image to the commit that produced it and the servers running it — and that will not let a retention policy delete an image production is running.
The registry protocol is unremarkable by design. Docker, Podman, BuildKit, containerd, Skopeo and Cosign work against it with no configuration beyond a hostname and credentials. The product is the layer above: a deployment ledger, and retention that is deployment-aware by construction.
$ mantle ledger status --repo acme/api
acme/api production · 2 host(s)
──────────────────────────────────────────────────────────────────────
NOW RUNNING v2.4.1 64679f025250 commit a3f81c2 deployed 3h ago
by nima · 2/2 hosts confirmed · reported via ansible
web-1, web-2
ROLLBACK TO v2.4.0 71bd9c1e4a02 commit 8e9d114 pinned ✓
TAGS v2.4.1 v2.4.0 main-20260814
STORAGE 12.4 GiB across 38 manifests · GC would free ~3.2 GiB
SOURCE https://github.com/acme/api
This repository implements the spine of the specification: a registry that
genuinely works end to end, with the differentiating ledger layer on top.
docs/STATUS.md states precisely what is implemented, what is
partial, and what is not built — read it before relying on anything.
In short: M1 and most of M2 are here, plus the M3 ledger, and a read-only
web interface (mantle-ui). The S3 driver, ACME, webhook delivery, replication,
scanning, and OIDC are not.
curl -fsSL https://raw.githubusercontent.com/bugloper/mantle/main/contrib/install.sh | shDetects your platform, fetches the mantle binary from GitHub Releases, checks
it against the published SHA-256, and installs it somewhere already on your
PATH — no privilege escalation unless you point it somewhere that needs it.
MANTLE_VERSION pins a version and MANTLE_INSTALL_DIR chooses the location.
That pattern runs whatever the server returns, so if you would rather read it
first — and contrib/install.sh is written to be read:
curl -fsSLO https://raw.githubusercontent.com/bugloper/mantle/main/contrib/install.sh
less install.sh && sh install.shThis installs the CLI only. The registry daemon needs a database and a configuration file, so it ships as a container image — see below.
Two ways in. Docker needs nothing else installed; the source path gives you the development loop.
docker compose up --build -d
docker compose run --rm -e MANTLE_ADMIN_PASSWORD='choose-something' mantled --bootstrapThat builds two images from the one Dockerfile — mantled and
the optional mantle-ui — starts them alongside PostgreSQL 16, and creates the
first administrator.
To run published images instead of building from this checkout — which needs
nothing from this repository — docs/docker.md has a compose
file that pulls bugloper/mantle and bugloper/mantle-ui, plus TLS, upgrade
and backup guidance. Both images are built for linux/amd64 and linux/arm64.
Either way you get:
registry http://localhost:5000
interface http://localhost:5180 (admin / choose-something)
The registry is configured entirely through MANTLE_* environment variables in
docker-compose.yml; there is no config file in the
image, which is why --bootstrap above passes no --config. Metrics are served
on :9090 inside the container and deliberately not published.
Two things to know before you change the port. server.domain is what the
token flow advertises to clients, so a different published port means changing
MANTLE_SERVER_DOMAIN to match, or the Docker login dance will send clients
back to a port nothing is listening on. And on macOS, ControlCenter's AirPlay
Receiver already holds :5000 and will answer instead of the registry — which
is the whole reason the source path below uses 5100.
This stack runs over plain HTTP on localhost. That is fine for evaluation and wrong for anything else.
You need Go 1.26+ and PostgreSQL 15+ running. Then one command:
make upThat builds all three binaries, creates a local database, bootstraps an administrator, and starts the registry and the web interface in the background:
registry http://127.0.0.1:5100
interface http://127.0.0.1:5180 (admin / devpassword)
metrics http://127.0.0.1:9190/metrics
make dev-seed pushes a few images with provenance labels and records
deployments against them, so there is something to look at. make status,
make logs and make down do what they say, make up is safe to re-run, and
make dev-clean drops the database and the state directory when you are done.
Running it for real, rather than locally
make build
createdb mantle
cp docs/mantle.example.yaml /etc/mantle/mantle.yaml
$EDITOR /etc/mantle/mantle.yaml # set domain, database.url, storage root, TLS
mantled --config /etc/mantle/mantle.yaml --check # validate before starting
# Create the first administrator. Idempotent — it will not replace an
# existing one.
MANTLE_ADMIN_PASSWORD='choose-something' mantled --config /etc/mantle/mantle.yaml --bootstrap
mantled --config /etc/mantle/mantle.yaml--bootstrap-username and --bootstrap-org override the account and default
organization --bootstrap creates. There is no mantle install yet, and no
packaging: no service unit, no .deb/.rpm, no published image. Migrations
are embedded and applied on startup under an advisory lock, so starting the
daemon is all that is needed to bring a schema up to date.
Then, from anywhere:
mantle login http://127.0.0.1:5100 --username admin --token 'choose-something'
mantle org create acme
mantle setup --repo acme/api # creates tokens, prints the snippetsmantle setup prints a builder token, a pull token, the provenance labels to add
to your build, and the deploy-reporting snippet for your deploy tool.
mantle login stores credentials in ~/.config/mantle/credentials.yaml;
MANTLE_CLI_CONFIG points it somewhere else, which is how the seed script keeps
out of your real profile. mantle doctor checks a live instance — the
database, pending migrations, storage, and the garbage collector's sweep — and
names the next action for every failing check. It reads /readyz, which answers
without credentials, so it still works when the credential is the problem.
mantle-ui --registry http://127.0.0.1:5100 # then open http://127.0.0.1:5180mantle-ui is a separate, optional binary (§14.3). It holds no database
connection and no privileged access — it is an ordinary client of the same
/api/v1 the CLI uses, and it proxies your browser's credential through rather
than holding one of its own. The registry runs perfectly well without it.
It shows the instance overview, repositories, the deployment ledger, organizations, identities and storage, and it can create organizations, repositories, users and tokens; change visibility and tag immutability; revoke credentials; delete repositories; and run garbage collection.
Every one of those is authorised by the registry against your own credential, not by the interface. What the interface decides is only what to offer — so a reader sees fewer buttons, and a non-administrator sees a note instead of the panels they cannot read.
mantle-ui --registry http://127.0.0.1:5100 --read-only--read-only refuses every state-changing request at the proxy, turning the
interface back into a dashboard with the CLI as the only way to make changes.
That is the posture §14.3 recommends, and it is worth considering for anything
reachable beyond your own machine.
Assets are embedded in the binary: no Node runtime, no build step.
Push an image built by any modern builder and Mantle reads the commit from the standard OCI labels the builder already wrote:
docker buildx build \
--label org.opencontainers.image.revision="$(git rev-parse HEAD)" \
--label org.opencontainers.image.source="$(git remote get-url origin)" \
-t registry.example.com/acme/api:v2.4.1 --push .No hook, no agent, no plugin. Where labels are absent, Mantle falls back to tag-shape inference and records that the result was a guess — so a wrong inference is diagnosable rather than mysterious.
One HTTP call from whatever already deploys upgrades the record to reported:
mantle deploy record --repo acme/api --digest "$DIGEST" \
--env production --host "$(hostname)" --status active || trueThat image is now pinned. It is a root in the garbage collector's reachable set and excluded from every retention policy, and the database itself refuses to delete a manifest a deployment references — so the guarantee survives a bug in the collector, not merely a correct implementation of it.
The || true is not a suggestion. Recording a deploy must never be able to fail
a deploy, and every shipped snippet is failure-tolerant.
mantle setup --pack prints this for Compose, Kamal, systemd, Ansible, CI or
curl. For Kamal there is also
kamal-mantle, which provisions
Mantle itself — kamal setup stands the registry up before it pushes, and every
deploy lands in the ledger. docs/kamal.md covers both the gem
and the by-hand path.
mantled (one Go binary) PostgreSQL filesystem
:443 /v2/* OCI API metadata content-addressed
/auth/* tokens manifests blobs only
/api/v1 admin API ledger
:9090 metrics, health audit
in-process workers (leader-elected via advisory lock):
gc · ledger · partition maintenance
Three design decisions are load-bearing and worth knowing about:
Manifests live in Postgres, not the blob store. They are small, extremely
hot, and must be byte-exact. Serving them from a row removes a storage
round-trip from every pull and eliminates the "manifest exists in the database
but not in storage" class of inconsistency. The cost is that pg_dump becomes
load-bearing for image retrieval.
Garbage collection is online and two-phase. A grace window, transactional
edges declared ON DELETE RESTRICT, and a quarantine period each independently
prevent the classic upload-versus-collect race. Deletion is recoverable for a
week by flipping a column.
Nothing imports the distribution package. The pull path is the only path
where an outage is a production incident, so no product feature may become a
dependency of it. The ledger is reached through a narrow events.Sink
interface, and the rule is enforced by a test in test/architecture.
Resolution order is flags > MANTLE_* environment > file > defaults, and
every default is chosen to be safe when left alone. The full annotated surface
is docs/mantle.example.yaml;
docs/mantle.dev.yaml is the loopback-only template
make dev-setup fills in.
mantled reads /etc/mantle/mantle.yaml unless --config or MANTLE_CONFIG
says otherwise. A missing file at the default path is not an error — running
entirely from the environment is a supported deployment — but a missing file at
a path you named explicitly is. Every environment variable mirrors a YAML key:
storage.filesystem.root is MANTLE_STORAGE_FILESYSTEM_ROOT.
Validation names the key, the value it got, and the range it accepts, and
mantled --check runs it without starting anything. Options that exist in the
schema but have no implementation behind them — storage.driver: s3,
server.tls.mode: acme, auth.oidc — fail loudly at startup rather than
appearing to work.
make help # every target, with a one-line description
make up # build, bootstrap, and start everything in the background
make down # stop it
make status # is it healthy?
make logs # follow both logs
make dev-seed # push sample images and deployments
make dev-clean # stop, drop the database, remove the state directory
make build # all three binaries into ./bin
make mantled # or just one of them — also mantle, mantle-ui
make clean # remove ./bin
make test # unit tests (no database needed)
make test-all # everything, against a real PostgreSQL
make test-race # everything, under the race detector
make lint # gofmt + go vet + the dependency rule
make tidy # go mod tidy
make dev-setup # create the local database and bootstrap an admin
make dev # run the registry in the foreground
make dev-ui # run the web interface in the foreground
make release-dry-run # build the images for every architecture, push nothing
make release # build and publish to Docker Hubmake release runs contrib/release.sh, which publishes
two things: the container images to Docker Hub, and a GitHub Release carrying
the CLI binaries for macOS and Linux on amd64 and arm64 — which is what
contrib/install.sh fetches. --skip-images and --skip-binaries do one
without the other.
It refuses a dirty tree or an untagged commit, asks for confirmation before
publishing anything public, and afterwards checks that the image reports the
version it claims, that the manifest carries every architecture, and that a
freshly built CLI archive actually runs.
docs/releasing.md is the full runbook, including how to
test a release end to end without publishing anything.
Binaries build to a temporary name and are moved into place, so rebuilding while a daemon is running is safe. Overwriting a running executable in place invalidates its code signature and macOS kills the process outright, which is a confusing way to lose a registry mid-session.
The interface can be iterated on without rebuilding: mantle-ui --assets cmd/mantle-ui/assets serves from disk instead of the embedded copy.
Tests run against a real PostgreSQL rather than a mock, because Mantle's correctness claims are largely claims about transactions and constraints. Point them at a server with:
export MANTLE_TEST_DATABASE_URL="postgres://$USER@localhost/postgres?sslmode=disable"Each test gets a throwaway database cloned from a migrated template, so the
suite is fast and tests cannot interfere with one another. Tests needing a
database skip themselves when that variable points at nothing reachable, which
is why make test works on a machine with no PostgreSQL and make test-all is
the honest run.
cmd/mantled the daemon
cmd/mantle the CLI — every command is an API call
cmd/mantle-ui the web interface — separate, optional, embedded assets
internal/oci digests, names, manifests (leaf package)
internal/distribution the /v2 HTTP surface, and nothing else
internal/catalog repositories, blobs, manifests, tags (Postgres)
internal/storage the blob driver interface and the filesystem backend
internal/auth identities, tokens, RBAC
internal/ledger the deployment ledger — the differentiator
internal/gc online garbage collection
internal/events the narrow sink the pull path publishes through
internal/admin the /api/v1 surface
internal/server assembly, middleware, background workers
internal/config the configuration surface and its validation
internal/migrate embedded migrations, checksum-verified
internal/observability metrics, structured logs, credential scrubbing
internal/testsupport throwaway databases and fixtures
test/integration end-to-end, over real HTTP against real Postgres
test/architecture the dependency rule, enforced
docs/ status, config templates, Docker/Kamal/release guides
contrib/ install.sh, dev-seed.sh, release.sh
Apache-2.0 (D-03). The deployment ledger stays in the free core: it is the reason to adopt Mantle, and putting the differentiator behind a paywall would mean nobody discovers it.