Self-hosting

One static binary. No runtime to install, no OpenSSL to patch.

ledge builds to a statically linked musl binary on a scratch image: no shell, no package manager, no system libraries. The project directory is a bind mount, the three ports are the same three you develop against, and a second build profile compiles out everything that is not the EDS story.

Fig. 1
Four stages, one image
flow
Editor
A glibc Rust stage builds the WebAssembly editor bundle, pre-installing its three build-time tools itself so one dropped download cannot discard a five-minute compile.
false
Warm
A musl stage builds the dependency graph against stubbed crate roots, so editing the server does not rebuild everything under it.
false
Build
The real server, built for x86_64 musl with the editor bundle embedded. jemalloc is the allocator here and only here — musl’s own is slow, and host builds never compile it.
false
scratch
The final stage carries the binary and nothing else: 17.9 MB for the full build, 9.89 MB for the EDS-only one. Both measured, not estimated.
true

rustls throughout, never OpenSSL — including the WebAuthn implementation, which was chosen for that reason, and the HTTPS client the importer and the git code store use. A test greps the lockfile, so a dependency that reintroduced OpenSSL would fail CI rather than the static link. The same choice is why importing over HTTPS works on an image with no certificate store: the roots are compiled in.

Running it

Health checks without a shell

# the Dockerfile is self-contained — nothing to prepare on the host
docker build -t ledge .

# the project directory is a bind mount at /data; the authoring port is
# published to loopback only — put your own proxy in front of it
docker run -d --name ledge \
  -v /srv/my-eds-project:/data \
  -p 3000:3000 -p 3002:3002 -p 127.0.0.1:3001:3001 \
  -e RUST_LOG=info \
  ledge

# the same probe the image's HEALTHCHECK runs, with the detail turned on
docker exec ledge /ledge healthcheck --verbose

A scratch image has no shell, so the container health check execs a subcommand of the binary itself: a synchronous TCP probe of the delivery port, where any HTTP response at all counts as healthy. Adding --verbose prints the response time, the version the server advertises, and per-port availability — informational only, since the exit code still reflects the delivery probe alone.

ledge terminates no TLS. It serves cleartext HTTP/1.1 and h2c and expects a TLS-terminating reverse proxy in front, which is also where browser HTTP/2 comes from. All three ports bind; what is reachable is whatever your proxy publishes. Logging is RUST_LOG, defaulting to info — with the import report, the startup admin code and the health-check output printing directly to standard output regardless, because those are command results rather than log events.

What the ports cache differs on purpose. Published HTML is served from memory behind a 60-second window and a weak content-hash ETag, and invalidated the instant you publish. Published code assets get two hours. A media URL whose content digest still matches the file it names gets a year and immutable; one whose digest has fallen behind still resolves to the current bytes, but at the short lifetime, so a CDN can never pin a stale URL to new bytes. Delivery and authoring serve their HTML uncached, because that is where you are working.

measure

Build profiles

Two builds from one source tree

The default build is everything. The EDS-only build compiles out every ledge-specific extension that carries a heavy dependency or an admin surface of its own, and keeps the whole of the EDS story: delivery, authoring, publishing, media, derived outputs. Measured off the real dependency graph and image build, that is 477 crates and 17.9 MB against 304 crates and 9.89 MB — the front page carries the capability-by-capability table.

Delivery output is asserted identical across the two. Both profiles run against the same render snapshots, with no per-profile variants, so dropping half the feature set cannot change a byte of HTML. A construct the smaller build cannot render is never rejected either — it falls through to the generic EDS block DOM, the same markup a template-less block already produces — and if the project contains block templates this build cannot render, the server says so once at startup, with a count, rather than quietly serving different HTML. A dropped endpoint is simply never mounted, and the editor already knows not to ask for it.

measure
warning
What the EDS-only build gives up

Plugins, block templates, server-rendered forms, authoring authentication, page history, cache rules, the git code store, metrics and minification. One of those changes the security posture rather than the feature list: with authentication compiled out, every capability guard becomes a no-op stand-in and the authentication routes are never mounted, so the server enforces nothing. That is why an [auth] section in that build is a fatal refusal at startup rather than a warning — silently ignoring it would leave an authoring port its operator believes is protected wide open — and why any build without authentication warns if the authoring port is bound to anything but loopback. These are also two profiles, not a menu: exactly the two are gated in CI, and every intermediate combination compiles on a best-effort basis and is not a tested product.

Auth and observability

Who reaches the authoring port, and what it tells you

Authentication is on the authoring port only; delivery and published carry no auth layer at all. A request resolves to a principal in a fixed order: loopback is an implicit admin unless you disable that bypass, then an allow-listed network range, then a session cookie, then the restricted startup-code session, then anonymous. Roles are read from the database on every request rather than baked into the cookie, so a role change takes effect on that user’s next request with no re-login.

Credentials are passkeys — there are no passwords to store, leak or reset. The bootstrap path is a random admin code printed to standard output on each boot, never written to a log, rotating every restart, whose only power is creating an account; that account is created through a single-use magic link that carries the roles an admin chose. A session cookie holds an opaque token and the database holds only its hash.

Forwarded-for headers are trusted only from peers you name. With no trusted proxies configured — the default — they are ignored entirely, and the socket peer is authoritative. There is a debug log line that prints the address a request actually arrived from, which is the value that belongs in that list and is otherwise a guess between a Docker host address and a Docker network one.

Metrics are configuration rather than environment variables. Point one key at a listen address and a Prometheus exporter serves it, with HTTP, publish-cache, rendition and process-memory series under a ledge_ prefix, on all three ports. Point another at Loki and structured events ship there — over rustls, like everything else.

measure
ledge.toml[auth]
public_origin   = "https://cms.example.com"  # WebAuthn origin — HTTPS
rp_id           = "cms.example.com"
trusted_proxies = ["10.0.0.0/24"]  # honour forwarded-for only from these
allowlist       = ["10.0.5.0/24"]  # trusted network = implicit admin

[server]
published_max_age = 60   # published HTML cache window, in seconds

[metrics]
addr     = "0.0.0.0:9100"
loki_url = "http://loki:3100"
env      = "prod"

[api]
public_page_export = true   # off by default; this site turns it on
note
There is nothing to download yet

No image to pull, no binary to fetch. The Dockerfile in the repository is self-contained, so docker build needs nothing prepared on the host, and the CI job that would push an image is gated on a registry secret being present. Images are x86_64 only — multi-architecture builds are on the deferred list. And if you install from source, install it the way the project’s own recipe does: the editor bundle is only genuinely embedded in a release build, so a plain cargo install without the embed feature produces a binary whose editor 404s as soon as you run it away from the source tree.

Keep reading

Two more pieces of the same system

02
EDS compatibility

Why the smaller build can drop half the feature set without changing a byte of delivery HTML, and what pins that down.

EDS compatibility
01
Local-first authoring

What the authoring port is for, and why publishing is a separate, per-page act rather than a deploy.

Local-first authoring
waitlist

Waitlist

One mail, when there is a binary.

No newsletter, no drip sequence, no passing your address to anyone. One message when ledge is downloadable, and that is the entire promise.