Packaging an Elixir/Phoenix application with Nix
Packaging Elixir applications with Nix offers a way to manage dependencies and ensure consistent builds across different environments. This article shows how you can leverage Nix to simplify and improve your Elixir project workflows.
Why Nix?
Do you have an existing NixOS system you want deploy to? Do you require the guarantees? Want you to leverage Nix’s testing ecosystem? Have issues with dependencies being the wrong version? Want an alternative to Docker? Just curious?
I won’t be selling Nix to you in this article but if you’re curious about it’s main selling points and about businesses using it here are some great resources on that:
- How Nix works - simple explanation about Nix’s(as a package manager) features
- nix-companies - github repo with a curated list of companies using Nix in production
- What is Nix - article by Burke Libbey from Shopify giving a great explanation of Nix
Prerequisites
Do I need NixOS to use Nix?
Short answer: no. Nix is a cross platform package manager for (u)nix based systems. It does support:
- any GNU/Linux distro (i686, x86_64, aarch64(ARM))
- MacOS (x86_64, aarch64(ARM / M-series chips))
NixOS does make managing/using Nix packages easier as one would expect.
Your Elixir application
This guide assumes your Elixir application:
- only uses packages from Hex(no git/github deps)
- uses NPM for installing JS packages(not using a package manager and vendoring is fine as well)
Why no external packages? This is possible but requires packaging those dependencies using Nix separately and copying them over at an appropriate step. This requires some more trickery and thus I decided to leave this as an exercise for the reader.
Warning for new users: Nix won’t allow network requests of any kind during the build steps unless they are done through appropriate Nix fetchers such as fetchFromGithub
.
Covering other package managers for JS would be a book in itself thus I decided that covering the most common one: npm
would be sufficient. If you’re using yarn
you’re in luck since it has even better support than npm
. When using things such as bun
you’re going to have to put a lot of elbow grease to make it work unfortunately.
Deployment
This guide will not cover deploying to a server. But it will show how to run your packaged application locally.
Here’s some code used to deploy the test app in this article in case you’re curious: GitHub commit
Open to using flakes
This article will use an experimental feature: flakes
and assumes that you have it enabled in your nix.conf
or NixOS system configuration. Otherwise you’ll have to pass --experimental-features 'nix-command flakes'
to every nix
invocation.
In case you’re not familiar: Flakes intro
If you’re opposed to using flakes this guide still may be used but you’ll need to move most of the code to default.nix
.
Getting started
For this article I’ve started a new Phoenix project and removed(here and here) the added by default github dependency(heroicons
) as well as added lucide icons through npm
.
Also generated a generic portfolio webpage using Vercel’s v0(commit)
You can take a look at the website’s code here
It’s online at https://ravensiris.xyz/ <- just a static site for now. Hopefully I don’t forget to make it interesting later.
Let it snow
Here’s the finished product. You can peek at it while reading the more comprehensive explanation about its parts:
The inputs
{
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable";
flake-utils.url = "github:numtide/flake-utils";
npmlock2nix = {
url = "github:nix-community/npmlock2nix";
flake = false;
};
};
}
For a flake to work you will need to add a reference to nixpkgs
which in our case is: github:nixos/nixpkgs/nixpkgs-unstable
.
This reference will allow us to leverage Nix’s bleeding edge package store(thus use newer version of Elixir and Erlang which may not be added to the stable store yet). Take caution since the more dynamic nature of nixpkgs-unstable
might lead to some issues/security vulnerabilities. In case it would be a concern I suggest sticking to a stable release version and using overrides for package versions that are not merged in yet.
Next up we’re using a commonly used library flake-utils. It has some useful functions for packaging and is widely used by the community(GitHub search).
Finally to make Nix work with npm
lockfiles we’re going to use npmlock2nix. Which is unfortunately not packaged as a flake. But we still can utilize it by passing flake = false;
.
As you might notice, you can treat the inputs
section as a way to declare imports from other Nix repositories. Dependencies used here will be tracked in flake.lock
file and will remain reproducible.
The outputs
outputs = {
self,
nixpkgs,
flake-utils,
npmlock2nix,
}:
flake-utils.lib.eachDefaultSystem (system: let
pkgs = nixpkgs.legacyPackages.${system};
erl = pkgs.beam.interpreters.erlang_27;
erlangPackages = pkgs.beam.packagesWith erl;
elixir = erlangPackages.elixir;
nodejs = pkgs.nodejs_22;
npm2nix = pkgs.callPackage npmlock2nix {};
in {
# ...
}
For the output we’re utilizing eachDefaultSystem
from flake-utils
.
eachDefaultSystem
will generate an output for each architecture supported by Nix.
For someone new to Nix it might not look like it but we’re actually passing an argument to eachDefaultSystem
in form of a lambda function that takes a single system
parameter. If you’re unfamiliar with the syntax read on here.
So when we’re defining a default
package it would generate something like this:
nix-repl> outputs.packages.<TAB>
outputs.packages.aarch64-darwin outputs.packages.aarch64-linux outputs.packages.x86_64-darwin outputs.packages.x86_64-linux
nix-repl> outputs.packages.x86_64-linux.<TAB>
outputs.packages.x86_64-linux.default outputs.packages.x86_64-linux.nixosModule
Simple but nifty.
If you’re unfamiliar with let ... in {expr}
blocks they are a way that we can define variables in Nix. So:
let
x = 8080;
in
{
service.my-service.port = x;
}
would set the option port
from service.my-service
to the value of 8080
.
In Nix you can nest and shadow variables:
let
x = 8080;
in
{
service.my-service = let
x = 9090;
y = true;
in
{
port = x;
openFirewall = y;
};
}
So the value of service.my-service.port
will be set to 9090
and service.my-service.openFirewall
will equal true
.
So in our first let ... in {}
we’ll be setting the dependency versions:
Setting the pkgs
is important as it will be easier to refer to packages below.
pkgs = nixpkgs.legacyPackages.${system};
Here we are setting the version of Erlang to OTP27(we are not locking to a specific minor version as it’s not really needed but it’s possible using overrides).
The version of elixir will be the newest one available in nixpkgs-unstable
for OTP27. As it’s not important for this project I didn’t lock it to any specific one(again possible with overrides). And we’re using Node with major version 22(you might need to adjust depending on your package.lock
version).
erl = pkgs.beam.interpreters.erlang_27;
erlangPackages = pkgs.beam.packagesWith erl;
elixir = erlangPackages.elixir;
nodejs = pkgs.nodejs_22;
And finally we’re importing npmlock2nix
.
npm2nix = pkgs.callPackage npmlock2nix {};
You can read a bit more about callPackage
here: callPackage design pattern
Also in case you’re unfamiliar with the syntax of a lambda function in nix read here:
The package set
Now theres a bunch of code inside our lambda eachDefaultSystem(system: {...})
Let’s focus on the package = ...
part of it for now.
packages = let
version = "0.1.0";
src = ./.;
mixFodDeps = erlangPackages.fetchMixDeps {
inherit version src;
pname = "ri-elixir-deps";
sha256 = "sha256-8aSihmaxNOadMl7+0y38B+9ahh0zNowScwvGe0npdPw=";
};
translatedPlatform =
{
aarch64-darwin = "macos-arm64";
aarch64-linux = "linux-arm64";
armv7l-linux = "linux-armv7";
x86_64-darwin = "macos-x64";
x86_64-linux = "linux-x64";
}
.${system};
npmDeps = npm2nix.v2.node_modules {
src = ./assets;
nodejs = nodejs;
};
in rec {
# inner expression
}
We’re making use of the let ... in {}
block to define some more variables that are only relevant to our package definition. So everything in the code block above will be available inside # inner expression
.
The first one is version = "0.1.0";
which is our application’s version. I strongly suggest following semantic versioning for this as well as keeping it same as what we have in our mix.exs
.
Second one is src = ./.
. In nix values starting with a .
refer to the Git repo’s root. So you can imagine that src
is set to a copy of your repo’s directory.
Third one mixFodDeps
is a special definition that utilizes fetchMixDeps
function built in to nixpkgs
. It will fetch all the dependencies we declared in our mix.exs
and lock that to a hash sha256 = "..."
.
Don’t worry. You don’t need to know how that value is computed. If you don’t know what it is just set sha256 = "";
(empty string). When invoking nix build
it will assume a default hash value and then fail while printing the computed value.
pname
is arbitrary. Just set it to whatever makes sense to you.
inherit version src
will copy over values of version
and src
from above. It’s the same as you’d set it like this:
mixFodDeps = erlangPackages.fetchMixDeps {
version = version;
src = src;
pname = "ri-elixir-deps";
sha256 = "sha256-8aSihmaxNOadMl7+0y38B+9ahh0zNowScwvGe0npdPw=";
};
The fourth one won’t make sense yet.
translatedPlatform =
{
aarch64-darwin = "macos-arm64";
aarch64-linux = "linux-arm64";
armv7l-linux = "linux-armv7";
x86_64-darwin = "macos-x64";
x86_64-linux = "linux-x64";
}
.${system};
Just notice that we’re calling the set({}
) with ${system}
.
It functions the same as the below elixir code:
iex(1)> system = "x86_64-linux"
"x86_64-linux"
iex(2)> translated_platform = %{
...(2)> "aarch64-darwin" => "macos-arm64",
...(2)> "aarch64-linux" => "linux-arm64",
...(2)> "armv7l-linux" => "linux-armv7",
...(2)> "x86_64-darwin" => "macos-x64",
...(2)> "x86_64-linux" => "linux-x64"
...(2)> }[system]
"linux-x64"
Lastly we have this block:
npmDeps = npm2nix.v2.node_modules {
src = ./assets;
nodejs = nodejs;
};
Here we’re utilizing npmlock2nix.v2.node_modules
function to turn our package.lock
into something Nix can understand. Later we can access it as a reference to a populated node_modules
directory(that is exactly like you’d run npm install
but managed by Nix instead). Read more on how the npmlock2nix.v2.node_modules
works here.
Finally a package definition
Now let’s delve inside # inner block
.
default = erlangPackages.mixRelease {
inherit version src mixFodDeps;
pname = "ravensiris-web";
preInstall = ''
ln -s ${pkgs.tailwindcss}/bin/tailwindcss _build/tailwind-${translatedPlatform}
ln -s ${pkgs.esbuild}/bin/esbuild _build/esbuild-${translatedPlatform}
ln -s ${npmDeps}/node_modules assets/node_modules
${elixir}/bin/mix assets.deploy
${elixir}/bin/mix phx.gen.release
'';
};
# ignore this for now
nixosModule = {...};
Naming your package exposed in Nix flake’s output default
is a common pattern for flakes. But this name is completely arbitrary. You can change it to whatever you want. But keep in mind most Nix users will be looking for a default
output.
Our default
package definition is using mixRelease
function again built in to nixpkgs
. We’re copying the values of version
, src
, mixFodDeps
from let
blocks above. pname
value again is arbitrary(but keep it unique and sensible for fellow Nix users sake).
Interesting part here is the preInstall
definition. It’s essentially a fancy shell script. Let’s go over line by line:
"ln -s ${pkgs.tailwindcss}/bin/tailwindcss _build/tailwind-${translatedPlatform}"
Why are we copying anything to a _build/
directory? Seems strange at first. But it’s a workaround for the fact that Nix doesn’t allow any network requests during building(with exception for fetchers).
The tailwind library we’re using in our mix.exs
deps({:tailwind, "~> 0.2", runtime: Mix.env() == :dev}
) is trying to download a copy of tailwind executable from here if it doesn’t find it already. This will fail during Nix’s build step. Thus we need to supply a copy of tailwind from Nix instead. If you looked at the tailwind releases page you’d notice that they provide binaries for multiple architechtures such as: linux-x64
. Nix does in fact support most of these architectures but the naming scheme is different. linux-x64
would be x86_64-linux
in Nix terms. Thus we’re utilizing that translatedPlatform
variable to rename the symlink so that Elixir’s tailwind library may find it.
What symlink? ${pkgs.tailwindcss}/bin/tailwindcss
will actually expand to something like this /nix/store/p0l7kjqq5ppc8wgrrj889bw91ds9pgc1-tailwindcss-3.4.3/bin/tailwindcss
. This points to a valid path in our systems Nix store. By symlinking it we don’t waste storage on additional copies and other programs that share the same version of tailwind have an oppurtunity to reuse it.
We’re doing the exact same thing for esbuild
.
"ln -s ${pkgs.esbuild}/bin/esbuild _build/esbuild-${translatedPlatform}"
Next we’re symlinking the node_modules
directory we’ve generated with npmlock2nix
.
"ln -s ${npmDeps}/node_modules assets/node_modules"
This way ESBuild will be able to find all the dependencies needed.
Next we’re running assets.deploy
as per Phoenix’s docs
"${elixir}/bin/mix assets.deploy"
Lastly we’re running
"${elixir}/bin/mix phx.gen.release"
Which will put some additonal scripts(server
, migrate
) into our output directory which will make running our app easier.
Let’s build and run
If you’ve been following everything you should already have all the pieces that are required to build our package. Now checkout everything in a git repo and run nix build
. It will take a while and you might need change some sha256
values. When you finally get a successful build a new symlinked directory should appear in the root of your project result
.
Our application’s binary should be now available under result/bin/server
.
Try running it. It should fail due to missing environment variables. Let’s set them for testing:
# random value. doesn't really matter but it's mandatory to be set.
export RELEASE_COOKIE="$(dd if=/dev/urandom bs=64 count=1 | base64)"
# here replace to a connect to a local instance of postgres running on your system
export DATABASE_URL="postgres:///postgres"
# required secret
export SECRET_KEY_BASE="$(dd if=/dev/urandom bs=64 count=1 | base64)"
result/bin/server
If everything was set properly a server should be running at http://localhost:4000
.
Now you can publish your repository on github and use it as an input for your system/server configuration.
For NixOS users
As I stated above NixOS users can enjoy even more power than regular nix
users.
For that we can create a NixOS module which will allow NixOS users to configure our web server from their Nix configuration instead of using environmental variables.
The final piece is this:
nixosModule = {
config,
lib,
pkgs,
...
}: let
cfg = config.services.ravensiris-web;
user = "ravensiris-web";
dataDir = "/var/lib/ravensiris-web";
in {
options.services.ravensiris-web = {
enable = lib.mkEnableOption "ravensiris-web";
port = lib.mkOption {
type = lib.types.port;
default = 4000;
description = "Port to listen on, 4000 by default";
};
secretKeyBaseFile = lib.mkOption {
type = lib.types.path;
description = "A file containing the Phoenix Secret Key Base. This should be secret, and not kept in the nix store";
};
databaseUrlFile = lib.mkOption {
type = lib.types.path;
description = "A file containing the URL to use to connect to the database";
};
host = lib.mkOption {
type = lib.types.str;
description = "The host to configure the router generation from";
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = cfg.secretKeyBaseFile != "";
message = "A base key file is necessary";
}
];
users.users.${user} = {
isSystemUser = true;
group = user;
home = dataDir;
createHome = true;
};
users.groups.${user} = {};
systemd.services = {
ravensiris-web = {
description = "Start up the homepage";
wantedBy = ["multi-user.target"];
script = ''
# Elixir does not start up if `RELEASE_COOKIE` is not set,
# even though we set `RELEASE_DISTRIBUTION=none` so the cookie should be unused.
# Thus, make a random one, which should then be ignored.
export RELEASE_COOKIE=$(tr -dc A-Za-z0-9 < /dev/urandom | head -c 20)
export SECRET_KEY_BASE="$(< $CREDENTIALS_DIRECTORY/SECRET_KEY_BASE )"
export DATABASE_URL="$(< $CREDENTIALS_DIRECTORY/DATABASE_URL )"
${default}/bin/migrate
${default}/bin/server
'';
serviceConfig = {
User = user;
WorkingDirectory = "${dataDir}";
Group = user;
LoadCredential = [
"SECRET_KEY_BASE:${cfg.secretKeyBaseFile}"
"DATABASE_URL:${cfg.databaseUrlFile}"
];
};
environment = {
PHX_HOST = cfg.host;
# Disable Erlang's distributed features
RELEASE_DISTRIBUTION = "none";
# Additional safeguard, in case `RELEASE_DISTRIBUTION=none` ever
# stops disabling the start of EPMD.
ERL_EPMD_ADDRESS = "127.0.0.1";
# Home is needed to connect to the node with iex
HOME = "${dataDir}";
PORT = toString cfg.port;
};
};
};
};
};
The name nixosModule
is arbitrary. But it’s a common pattern to name it this way so other Nix users can easily find it.
Our NixOS module takes some parameters:
{
config,
lib,
pkgs,
...
}: {}
You should not worry about them. They will be injected by Nix automatically when user of our module imports it like this:
# somewhere inside their host configuration
imports = [
our-nixos-module.outputs.packages.x86_64-linux.nixosModule
];
Then what’s left is 2 parts:
- definition of configurable options
- the configuration that get’s injected into the host when our service gets enabled
The options definition
options.services.ravensiris-web = {
enable = lib.mkEnableOption "ravensiris-web";
port = lib.mkOption {
type = lib.types.port;
default = 4000;
description = "Port to listen on, 4000 by default";
};
secretKeyBaseFile = lib.mkOption {
type = lib.types.path;
description = "A file contianing the Phoenix Secret Key Base. This should be secret, and not kept in the nix store";
};
databaseUrlFile = lib.mkOption {
type = lib.types.path;
description = "A file containing the URL to use to connect to the database";
};
host = lib.mkOption {
type = lib.types.str;
description = "The host to configure the router generation from";
};
};
This block is pretty self explanatory once you understand what it does. But if in doubt please read this.
The config
The config part that gets injected into hosts system is further split into multiple parts:
Assertions
assertions = [
{
assertion = cfg.secretKeyBaseFile != "";
message = "A base key file is necessary";
}
];
They allow you to give helpful messages to the user when required attributes are not set. NixOS will also make runtime assertions on it’s own(e.g. it will exit when you try to access an unassigned variable).
User and group definitions
To avoid running the server as root
or a user in sudoers
file we create one that will only be used for the purpose of running our server.
users.users.${user} = {
isSystemUser = true;
group = user;
home = dataDir;
createHome = true;
};
users.groups.${user} = {};
For additonal reference you may consult NixOS option search
SystemD unit
For the most important part we wrap our server in a SystemD unit. Here I send you off to reading some preexisting unit files and SystemD documentation. Making units can be hard but the template used for this article should be sufficient for most purposes.
systemd.services = {
ravensiris-web = {
description = "Start up the homepage";
wantedBy = ["multi-user.target"];
script = ''
# Elixir does not start up if `RELEASE_COOKIE` is not set,
# even though we set `RELEASE_DISTRIBUTION=none` so the cookie should be unused.
# Thus, make a random one, which should then be ignored.
export RELEASE_COOKIE=$(tr -dc A-Za-z0-9 < /dev/urandom | head -c 20)
export SECRET_KEY_BASE="$(< $CREDENTIALS_DIRECTORY/SECRET_KEY_BASE )"
export DATABASE_URL="$(< $CREDENTIALS_DIRECTORY/DATABASE_URL )"
${default}/bin/migrate
${default}/bin/server
'';
serviceConfig = {
User = user;
WorkingDirectory = "${dataDir}";
Group = user;
LoadCredential = [
"SECRET_KEY_BASE:${cfg.secretKeyBaseFile}"
"DATABASE_URL:${cfg.databaseUrlFile}"
];
};
environment = {
PHX_HOST = cfg.host;
# Disable Erlang's distributed features
RELEASE_DISTRIBUTION = "none";
# Additional safeguard, in case `RELEASE_DISTRIBUTION=none` ever
# stops disabling the start of EPMD.
ERL_EPMD_ADDRESS = "127.0.0.1";
# Home is needed to connect to the node with iex
HOME = "${dataDir}";
PORT = toString cfg.port;
};
};
};
Conclusion
In this article, we’ve explored how to package an Elixir/Phoenix application using Nix. We’ve covered:
- Setting up the necessary inputs for our Nix flake
- Defining our package and its dependencies
- Creating a NixOS module for easier deployment on NixOS systems
- Handling asset compilation and dependency management within the Nix ecosystem
By leveraging Nix, we’ve created a reproducible build process for our Elixir application, ensuring consistent deployments across different environments. This approach offers several benefits, including:
- Improved dependency management
- Consistent builds across different machines
- Easy integration with NixOS systems
- Potential for leveraging Nix’s testing ecosystem
While the learning curve for Nix can be steep, the long-term benefits in terms of reproducibility and maintainability make it a valuable tool in your deployment arsenal, especially for complex Elixir/Phoenix web applications.
Additional Resources
To further your understanding of Nix and its ecosystem, here are some helpful resources:
- Nix Pills - A comprehensive guide to Nix, starting from the basics
- NixOS Wiki - A community-driven wiki with various guides and best practices
- Phoenix Deployment Guides - Official Phoenix deployment documentation
- nix.dev - Learn nix by examples
- Nix Flakes: Exposing and using NixOS Modules - great article about NixOS modules
- Plausible service nix definition - Plausible’s nix service definition
Remember that the Nix ecosystem is constantly evolving, so it’s always a good idea to check the official documentation and community resources for the most up-to-date information.
Happy coding and deploying with Nix and Elixir!
FAQ
What is Nix, and why use it to package Elixir applications?
Nix is a package manager that ensures reproducible builds and consistency across environments. It simplifies dependency management, making it ideal for Elixir applications with complex setups.
Do I need NixOS to package an Elixir app with Nix?
No, Nix works across various Unix-based systems, including Linux and macOS. NixOS users, however, benefit from tighter integration.
What is a Nix flake, and why use it?
Flakes are an experimental feature in Nix offering better reproducibility and dependency handling, ideal for modern projects like Elixir/Phoenix apps.
How does Nix handle Elixir dependencies?
Nix can fetch Elixir dependencies declared in mix.exs
using built-in tools like fetchMixDeps
, ensuring consistent versions across builds.
How can Nix be used to handle JavaScript dependencies in Elixir projects?
Tools like npmlock2nix
can convert package.lock
files into a Nix-friendly format, managing JavaScript dependencies (e.g., through npm
or yarn
).
How do you compile assets in a Nix-packaged Phoenix app?
Nix can symlink precompiled binaries (like tailwindcss
and esbuild
) into your build directory, ensuring assets are handled during the Nix build process.
What is the advantage of using Nix for deployment?
Nix's reproducibility ensures that the same environment can be deployed consistently, with minimized risks of "works on my machine" issues.