Shell-free, distribution-hardened mix release for distroless images.
mix release emits a layered set of #!/bin/sh scripts as its launch interface
(bin/<name> → releases/<v>/elixir → erts-*/bin/erl), all of which
ultimately exec the native erlexec. On a distroless :nonroot image there is
no /bin/sh, so those scripts cannot run.
ShelllessRelease replaces them with a single native launcher (compiled from the
bundled priv/launcher/start.c) that execves the BEAM directly and pre-starts
distribution itself, so the image needs no shell. The launcher is a multi-call
binary installed over the existing bin/<name> entry points, preserving the
interface so nothing downstream changes.
On top of being shell-free, it hardens the release:
- Command whitelist — a fixed set of verbs (the server plus your declared
task verbs) baked into the launcher at build time. There is no generic
eval, so an attacker who controls the container's args cannot run arbitrary code. - Pinned distribution port — no ephemeral fallback, so the distribution port is firewallable.
- Mandatory mutual-TLS distribution — fail-closed without certs.
- Fail-closed cookie strength check.
- EPMD-less mode (optional) — drops the Erlang Port Mapper Daemon and port
4369 entirely. See
ShelllessRelease.EpmdLess.
Installation
Add shellless_release to your deps in mix.exs:
def deps do
[
{:shellless_release, "~> 0.1"}
]
endUsage
Add the hardening step to your release in mix.exs:
def project do
[
# ...
releases: [
my_app: [
steps: [:assemble, &ShelllessRelease.harden/1]
]
]
]
endConfigure it (all keys are optional):
config :shellless_release,
# Zero-arg task verbs -> compiled-in expressions (the whitelist). These
# replace `bin/<verb>` shell overlays; each runs non-distributed.
tasks: [
migrate: "MyApp.Release.migrate()",
seed: "MyApp.Release.seed()"
],
# Pinned Erlang distribution port (build-time constant; default 24369).
dist_port: 24369,
# Require TLS distribution + the cert bundle (default true). When false,
# distribution is cookie-only (only sensible for non-clustered apps).
require_tls: true,
# EPMD-less distribution (default true). Requires one node per IP.
epmdless: true,
# Extra entry-point names to install the launcher at, beyond "server" and
# the task verbs (rarely needed).
extra_entry_points: [],
# Remove the generated shell launchers after install (default true).
strip_shell_scripts: trueThe launcher is compiled by this step during mix release, which is expected
to run in your Dockerfile's builder stage where a C compiler (cc/gcc) is
present. The library itself ships only C source — it never compiles anything at
deps.compile time.
At runtime the entry points behave exactly like a stock release:
bin/server # boot the application
bin/migrate # run a whitelisted task verb (non-distributed)
Requirements
- Elixir
~> 1.15 - A C compiler (
ccorgcc) available in the release build stage — typically your Dockerfile builder image (e.g.build-essential).
Documentation
Full documentation is on HexDocs. Start
with the ShelllessRelease
moduledoc for the launcher, and
ShelllessRelease.EpmdLess
for EPMD-less distribution.
License
MIT — see LICENSE.