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>/elixirerts-*/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"}
  ]
end

Usage

Add the hardening step to your release in mix.exs:

def project do
  [
    # ...
    releases: [
      my_app: [
        steps: [:assemble, &ShelllessRelease.harden/1]
      ]
    ]
  ]
end

Configure 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: true

The 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 (cc or gcc) 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.