At BEAMOps, we care deeply about making development environments repeatable, powerful, and user-friendly. We love the confidence of knowing that things will just work. That’s why we love environment integrity, single responsibility and DRY.
I think I started using language servers around six years ago — and if I’m honest, I never really got them to work consistently. Sometimes they’d break silently, sometimes loudly. I never stopped to ask myself why.
The answer? Everything matters: the Erlang version, the Elixir version, how the language server is built, how it’s wired into Neovim (or your editor of choice)… the tiniest mismatch turns your environment into a disaster, or maybe just mine because I am a disaster ;).
In this post, we’ll walk through our latest solution: a bulletproof Elixir setup using Nix, Flakes, Lexical and Neovim, shaped by weeks of trial and error and the sharp edges of broken tooling. Whether you’re an individual dev or part of a team, this guide aims to give you a dev environment that just works — every time.
Why Dev Shells Matter
I’ve lost count of how many times my language server just… didn’t work.
Sometimes it was the wrong Elixir version. Sometimes the wrong Erlang version. Sometimes it was a weird compatibility issue. And sometimes… it was just Tuesday.
A good friend and colleague hit me with some tough love a few months ago:
Your environment is shit (referring to the LSP, I hope). It’s always broken.
I can’t even be mad. He had a point. 😢
Just yesterday, I tried to jump to a module using an alias — and nothing happened. The language server was failing silently. That was my breaking point. I decided: this ends now.
So, I wanted to fix it for good.
I’ve always used ElixirLS, but there’s been a lot of buzz about Lexical. It’s sponsored by Dashbit, it’s focused on correctness and speed, and—if you know me—you know I’m a bit obsessed with José Valim, so anything he touches is basically gold.
I also work on several LiveView projects, and Lexical claims to have better support for it out of the box.
What I wanted was simple (in theory):
- Figure out which language server to stick with
- Build it consistently
- Keep it up to date
- Use it across projects without random compatibility issues
But most importantly: I wanted confidence that once I built a dev shell, it would work — or at least let me know when it wouldn’t.
That led me to fully commit to building a reproducible dev environment with Nix and Flakes. One where:
- The language server is always built with the right Elixir & Erlang versions
- Any incompatibility is surfaced immediately
- And no warnings slip through the cracks
Building Lexical in a Reproducible Dev Shell
Before going all-in on Lexical, I first tried building ElixirLS. But here’s the
catch: it doesn’t compile cleanly with --warnings-as-errors
on the latest
Elixir and Erlang.
Here’s what I ran:
❯ mix compile --warnings-as-errors
…and here’s what I got:
==> language_server
warning: Mix.Dep.load_on_environment/1 is undefined or private
...
warning: :dialyzer_plt.from_file/1 is undefined or private
...
So much red. 😬
That’s not what I want when compiling the LSP, let alone in a reproducible build.
Back to the mission: we want to guarantee that our language server works.
To do that, we’ll:
- Build Erlang
- Use that Erlang to build Elixir
- Use that Elixir to build Lexical
Following the docs, it’s always best to align the versions used in your shell when building Lexical: Installation Caveats.
If you look at my personal dot-files…
You’ll find a dev_shell/
folder. This folder contains several flakes that I
use across projects via .envrc
and nix-direnv
.
If you add the following to your .envrc
:
echo "use flake github:gilacost/dot-files#elixir_latest_erlang_latest" >> .envrc
Then every time you cd into that project, Nix will load a fully pinned and reproducible Elixir + Erlang dev environment.
Think of it as asdf on steroids — but with way more control and flexibility.
Before we add Lexical to the mix, let’s review the current dev shell:
inputs:
let
overlays = [
(self: super: rec {
erlang = super.beam.interpreters.${inputs.erlangInterpreter}.override {
sha256 = inputs.erlangSha256;
version = inputs.erlangVersion;
};
elixir = (super.beam.packagesWith erlang).elixir.override (
{
sha256 = inputs.elixirSha256;
version = inputs.elixirVersion;
}
// (
if inputs ? elixirEscriptPath then
{
escriptPath = inputs.elixirEscriptPath;
}
else
{ }
)
);
})
];
system = inputs.system;
pkgs = import inputs.nixpkgs { inherit system overlays; };
in
pkgs.mkShell {
buildInputs =
with pkgs;
let
linuxPackages = lib.optionals (stdenv.isLinux) [
inotify-tools
libnotify
];
darwinPackages = lib.optionals (stdenv.isDarwin) (
with darwin.apple_sdk.frameworks;
[
terminal-notifier
CoreFoundation
CoreServices
]
);
in
builtins.concatLists [
[
erlang
elixir
]
linuxPackages
darwinPackages
];
shellHook =
let
escript = ''
Filepath = filename:join([
code:root_dir(),
"releases",
erlang:system_info(otp_release),
"OTP_VERSION"
]),
{ok, Version} = file:read_file(Filepath),
io:fwrite(Version),
halt().
'';
in
''
export OTP_VERSION="$(erl -eval '${escript}' -noshell | tr -d '\n')"
export ELIXIR_VERSION="$(${pkgs.elixir}/bin/elixir --version | grep 'Elixir' | awk '{print $2}')"
echo "🍎 Erlang OTP-$OTP_VERSION"
echo "💧 Elixir $ELIXIR_VERSION"
'';
}
Note: Nothing fancy here — we build Erlang, then use it to build Elixir. When you cd into a project using this shell, you should see something like:
🍎 Erlang OTP-27.3.2
💧 Elixir 1.18.3
Adding Lexical to the Flake
Now that our base shell reliably builds and pins the right Elixir and Erlang versions, it’s time to add Lexical to the mix.
Lexical is written in Elixir and uses the standard Mix toolchain, so building it within Nix is very doable — but it still takes a bit of care. We want to ensure:
- It’s built from a pinned Git revision
- It compiles with –warnings-as-errors (to prevent the unexpected)
- It packages correctly so we can reference it in our editor configuration
- It works across environments without requiring manual setup
Below is a mkDerivation
block that does exactly that. You can add it to your
overlay or integrate it into your dev shell flake as a named output (lexical)
and reference it in your PATH
.
lexical = super.stdenv.mkDerivation {
pname = "lexical";
version = inputs.lexicalVersion;
src = super.fetchFromGitHub {
owner = "lexical-lsp";
repo = "lexical";
rev = inputs.lexicalVersion;
sha256 = inputs.lexicalSha256;
};
buildInputs = [
self.elixir
super.git
super.makeWrapper
];
buildPhase = ''
export HOME=$(mktemp -d)
export PATH=$PATH:${super.git}/bin
export SSL_CERT_FILE=${super.cacert}/etc/ssl/certs/ca-bundle.crt
export GIT_SSL_CAINFO=${super.cacert}/etc/ssl/certs/ca-bundle.crt
mix local.hex --force
mix local.rebar --force
mix deps.get
mix deps.compile
mix compile --warnings-as-errors
'';
installPhase = ''
mix package --path "$out"
chmod +x $out/bin/*.sh
'';
};
Let’s walk through a few details:
- We fetch Lexical from GitHub using a specific revision and hash, ensuring full reproducibility.
- We override HOME to avoid polluting your real home directory during build time.
- We set up Hex and Rebar non-interactively so the build doesn’t fail on missing tooling.
- Finally, we run mix package which builds the LSP binaries inside $out folder, a common practice with nix.
Once that’s in place, you can include lexical in your buildInputs
or export it
as a binary path in your shellHook
. Your editor (e.g., Neovim or VSCode) can
then point to this compiled binary directly — and you’ll know that it was built
against the exact same tool chain as the rest of your project.
Patching start_lexical and the Version Manager: I want Nix, please
In the last section, we built Lexical using Nix and packaged it nicely via mix package
. However, if you try to run the resulting start_lexical.sh
script
inside the bin/
directory, you’ll likely hit this:
❯ ./_build/dev/package/lexical/bin/start_lexical.sh
No activated version manager detected. Searching for version manager...
Could not activate a version manager. Trying system installation.
That message comes from the activate_version_manager.sh
script, which is
invoked automatically by start_lexical.sh
. Its job is to detect tools like
asdf, kiex, or exenv and ensure the correct Elixir version is active.
The problem? There’s no activation support for Nix (yet) — so the script bails out and falls back to the system installation, which isn’t what we want in a Nix-based dev shell.
Until someone submits a patch upstream to support Nix (👀 maybe me?), we’ll work around it by patching the generated scripts after the package is built.
Here’s the updated installPhase
that does exactly that:
installPhase = ''
mix package --path "$out"
chmod +x $out/bin/*.sh
# Create a wrapper script with version support
mv "$out/bin/start_lexical.sh" "$out/bin/start_lexical.sh.orig"
cat > "$out/bin/start_lexical.sh" << EOF
#!/bin/sh
if [ "\$1" = "--version" ] || [ "\$1" = "-v" ]; then
echo "${inputs.lexicalVersion}"
exit 0
fi
exec "$out/bin/start_lexical.sh.orig" "\$@"
EOF
chmod +x "$out/bin/start_lexical.sh"
# Add Nix detection to version manager script
substituteInPlace "$out/bin/activate_version_manager.sh" \
--replace-fail 'activate_version_manager() {' '
activate_version_manager() {
if [ -n "$IN_NIX_SHELL" ]; then
echo >&2 "Using Nix-provided Elixir: $(which elixir)"
return 0
fi'
'';
Let’s break down what’s going on:
-
We wrap the original
start_lexical.sh
with a small shim that supports a--version
flag. This is useful for Neovim when using lsp config that query the version to verify the LSP. -
We patch the
activate_version_manager.sh
script to check for theIN_NIX_SHELL
environment variable. If present, it short-circuits the activation logic and just uses the Elixir version already provided by Nix.
Note: You don’t need to mess with asdf, kiex, or global installs. Nix already gives us reproducibility — we just need to convince Lexical to trust it.
This small patch is enough to get Lexical running smoothly inside a fully Nix-managed dev shell.
Making Lexical Available Globally
Now that we’ve built and patched Lexical, the final step is making it available system-wide — in a way that works seamlessly with your editor, no matter what shell or context you’re in.
I didn’t want to rely on setting vim.g.lsp_elixir_bin
in my Neovim config,
or exporting environment variables that might not persist outside a dev shell.
That approach felt brittle — especially when switching between projects or
rebuilding Lexical.
So, sticking with the Nix mindset, we reached for a reliable and reproducible trick: symbolic links.
In our shellHook
, we symlink the current Lexical build into a known global
location:
shellHook = ''
export LEXICAL_PATH="${pkgs.lexical}/bin/lexical"
export OTP_VERSION="$(erl -eval '${escript}' -noshell | tr -d '\n')"
export ELIXIR_VERSION="$(${pkgs.elixir}/bin/elixir --version | grep 'Elixir' | awk '{print $2}')"
echo "🍎 Erlang OTP-$OTP_VERSION"
echo "💧 Elixir $ELIXIR_VERSION"
echo "🧠 Lexical version: ${inputs.lexicalVersion}"
echo ""
# Remove existing directory/symlink if it exists
rm -rf "$HOME/.elixir-lsp/lexical"
# Link the correct lexical binary
ln -sf "${pkgs.lexical}/" "$HOME/.elixir-lsp/lexical"
echo "🔗 Symlinked Lexical to: $HOME/.elixir-lsp/lexical"
echo "🧠 Lexical available at: ${pkgs.lexical}/bin/start_lexical.sh"
'';
Why This Works
-
Most editors (like Neovim with nvim-lspconfig) can automatically detect and
run Lexical if it’s in a standard location like
~/.elixir-lsp/lexical/bin/start_lexical.sh
. - The symbolic link keeps that path constant, even when you upgrade or rebuild Lexical with a new version.
- You avoid needing to reload editor configs, fiddle with per-project paths, or export shell variables manually.
- It also gives you a centralised place to inspect or test the current build.
A Nice Bonus
When you enter the shell, you’ll see helpful version info printed:
🍎 Erlang OTP-27.3.2
💧 Elixir 1.18.3
🧠 Lexical version: 0.6.0
🔗 Symlinked Lexical to: /Users/you/.elixir-lsp/lexical
🧠 Lexical available at: /nix/store/…-lexical-0.6.0/bin/start_lexical.sh
Neovim Integration
With the start_lexical.sh
script reliably in place, I updated the Neovim
config to use it without relying on dynamic environment variables:
local lsp = require('lspconfig')
...
local elixir_lsp_path = vim.fn.expand("~/.elixir-lsp/lexical/bin/start_lexical.sh")
...
lsp.lexical.setup {
cmd = { elixir_lsp_path },
filetypes = { "elixir", "eelixir", "heex" },
}
This setup works seamlessly with direnv, and since the path never changes (thanks to our symlink trick), there’s no need to reload Neovim when switching shells.
Using direnv + Flakes
If you’re new to Nix and want to try this shell setup, you’ll first need to
install Nix and get direnv
up and running.
Once that’s done, you can use the reusable dev shell in any Elixir project by
adding this to the project’s .envrc
file:
use flake github:gilacost/dot-files#elixir_latest_erlang_latest
You now have a fully pinned Elixir + Erlang + Lexical stack per project — including global LSP support — with no extra setup.
If you’d like help setting this up for your team, simplifying your Elixir tooling and CI pipelines, or reviewing and migrating your infrastructure, feel free to reach out.
📬 Email us at info@beamops.co.uk
📅 Or book a free 30-minute call — we’d love to hear what you’re working on and explore how we can help.