Locally deploy phoenix liveview using nix

Are there any known working resources to package using nix and deploy it?

I’ve tried:

But the basic liveview(mix phx.new) uses heroicons and I get errors when building it.

nix build

...
       > Running phase: installPhase
       > Unchecked dependencies for environment prod:
       > * heroicons (https://github.com/tailwindlabs/heroicons.git - v2.1.1)
       >   lock mismatch: the dependency is out of date. To fetch locked version run "mix deps.get"
       > ** (Mix) Can't continue due to errors on dependencies
...

I’ve found the best resource to be the BEAM section of the nixpkgs manual. We’re currently building and deploying our platform using nix, and I used that guide as a starting point for the implementation.

I’ve been meaning to write a proper blog post about this at some point, but haven’t gotten around to it yet.

Do you have any specific questions you’d like help with?

Edit: You did actually pose a question, so let me try to help out:

But the basic liveview(mix phx.new) uses heroicons and I get errors when building it.

What does your mix.exs and flake.nix look like?

1 Like

Maybe try to doing aMIX_ENV=prod mix deps.get. If there is a lock mismatch, this could solve it.

I am not a big fan of just throwing your working directory in as source. Any changes made to files that are not related would result in a rebuild of everything, and all files in directory that are not related to a deployment would also be included. I suggest using the documentation provided and mix2nix for dependencies.

1 Like

error after running node2nix:

node:fs:562
  return binding.open(
                 ^

Error: ENOENT: no such file or directory, open 'package.json'
    at Object.openSync (node:fs:562:18)
    at Object.readFileSync (node:fs:446:35)
    at Object.npmToNix (/nix/store/w2s44ll9wl5k8ncdqyrbgvmyd1dj6p6w-node2nix-1.11.0/lib/node_modules/node2nix/lib/node2nix.js:46:29)
    at Object.<anonymous> (/nix/store/w2s44ll9wl5k8ncdqyrbgvmyd1dj6p6w-node2nix-1.11.0/lib/node_modules/node2nix/bin/node2nix.js:296:10)
    at Module._compile (node:internal/modules/cjs/loader:1554:14)
    at Object..js (node:internal/modules/cjs/loader:1706:10)
    at Module.load (node:internal/modules/cjs/loader:1289:32)
    at Function._load (node:internal/modules/cjs/loader:1108:12)
    at TracingChannel.traceSync (node:diagnostics_channel:322:14)
    at wrapModuleLoad (node:internal/modules/cjs/loader:220:24) {
  errno: -2,
  code: 'ENOENT',
  syscall: 'open',
  path: 'package.json'
}

The error is correct, assets/package.json doesn’t exist
Are there any commands I (or nix) should be running?

> tree assets
assets
├── css
│   └── app.css
├── js
│   └── app.js
├── tailwind.config.js
└── vendor
    └── topbar.js

These commands I’ve used

echo "use flake" > .envrc
echo ".direnv/" >> .gitignore


mix archive.install hex phx_new

mix phx.new . --module Proj --app proj

# Modify dev.exs by adding the db's port below the hostname:
sed -i '/hostname: "/a \ \ port: 5433,' config/dev.exs

# START POSTGREQL
pg-start

# 
mix deps.get

# Then configure your database in config/dev.exs and run:
mix ecto.create

# You can also run your app inside IEx (Interactive Elixir) as:
iex -S mix phx.server


cd assets
node2nix --development

flake.nix

{
  description = "General Elixir Project Flake 20250516";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
    flake-utils.url = "github:numtide/flake-utils";
  };

  outputs =
    {
      self,
      nixpkgs,
      flake-utils,
    }:
    flake-utils.lib.eachDefaultSystem (
      system:
      let
        pname = "proj";
        version = "0.0.1";

        pkgs = nixpkgs.legacyPackages.${system};
        ## Define Erlang with ODBC support
        erlang = pkgs.beam.interpreters.erlang_27.override {
          odbcSupport = true;
        };
        ## Create a custom beam package set using our ODBC-enabled Erlang
        erlangPackages = pkgs.beam.packagesWith erlang;
        ## Now we can create Elixir using our custom beam packages
        elixir = erlangPackages.elixir_1_18;

        nodejs = pkgs.nodejs_24;
        postgresql = pkgs.postgresql_17;

        # LANG = "C.UTF-8";
        LANG = "en_US.UTF-8";
        root = ./.;
      in
      {
        formatter = pkgs.nixpkgs-fmt;

        ## mix2nix is a cli tool available in nixpkgs. it will generate a nix expression from a mix.lock file. It is quite standard in the 2nix tool series.
        # nix run nixpkgs#mix2nix -- > mix_deps.nix
        # cd assets
        # nix run nixpkgs#node2nix -- --development
        packages =
          let
            src = ./.;
            mixNixDeps = with pkgs; import ./mix_deps.nix { inherit lib beamPackages; };
            nodeDependencies = (pkgs.callPackage ./assets/default.nix { }).shell.nodeDependencies;
          in
          {
            default = erlangPackages.mixRelease {
              inherit
                src
                pname
                version
                mixNixDeps
                ;

              postBuild = ''
                ln -sf ${nodeDependencies}/lib/node_modules assets/node_modules
                npm run deploy --prefix ./assets

                # for external task you need a workaround for the no deps check flag
                # https://github.com/phoenixframework/phoenix/issues/2690
                mix do deps.loadpaths --no-deps-check, phx.digest
                mix phx.digest --no-deps-check
              '';
            };
          };

        devShells.default = pkgs.mkShell {
          inherit LANG;
          PGPORT = "5433"; # default 5432

          # enable IEx shell history
          ERL_AFLAGS = "-kernel shell_history enabled";
          # # In IEX: `open Enum.map`
          # ELIXIR_EDITOR = "code --goto __FILE__:__LINE__";

          ## phoenix related env vars
          POOL_SIZE = 15;
          # DB_URL = "postgresql://postgres:postgres@localhost:5432/db";
          PORT = 4000;
          MIX_ENV = "dev";
          ## add your project env vars here, word readable in the nix store.
          # ENV_VAR = "your_env_var";

          ##########################################################
          # Without  this, almost  everything  fails with  locale issues  when
          # using `nix-shell --pure` (at least on NixOS).
          # See
          # + https://github.com/NixOS/nix/issues/318#issuecomment-52986702
          # + http://lists.linuxfromscratch.org/pipermail/lfs-support/2004-June/023900.html
          ##########################################################
          LOCALE_ARCHIVE =
            if pkgs.stdenv.isLinux then "${pkgs.glibcLocales}/lib/locale/locale-archive" else "";

          buildInputs =
            [
              pkgs.unixODBC
              pkgs.psqlodbc

              elixir
              nodejs
              postgresql

              pkgs.git
              pkgs.pgcli
              pkgs.glibcLocales
              pkgs.gnumake
              pkgs.gcc

              ## Testing
              pkgs.chromedriver

              ## Deploy tools
              pkgs.flyctl # fly.io
              # pkgs.gigalixir

              pkgs.mix2nix
              # Used for frontend dependencies, you are free to use yarn2nix as well
              pkgs.nodePackages.node2nix
              # Formatting .js file
              pkgs.nodePackages.prettier
              pkgs.elixir_ls # Elixir

              pkgs.nixpkgs-fmt
              # codespell --skip="./deps/*,./.git/*,./assets/*,./erl_crash.dump" -w
              pkgs.codespell
              # dot -Tpng ecto_erd.dot -o erd.png
              pkgs.graphviz

              (pkgs.writeShellScriptBin "pg-stop" ''
                pg_ctl -D $PGDATA -U postgres stop
              '')
              (pkgs.writeShellScriptBin "pg-reset" ''
                rm -rf $PGDATA
              '')
              (pkgs.writeShellScriptBin "pg-setup" ''
                ####################################################################
                # If database is not initialized (i.e., $PGDATA directory does not
                # exist), then set it up. Seems superfluous given the cleanup step
                # above, but handy when one gets to force reboot the iron.
                ####################################################################
                if ! test -d $PGDATA; then
                  ######################################################
                  # Init PostgreSQL
                  ######################################################
                  pg_ctl initdb -D $PGDATA
                  #### initdb --locale=C --encoding=UTF8 --auth-local=peer --auth-host=scram-sha-256 > /dev/null || exit
                  # initdb --encoding=UTF8 --no-locale --no-instructions -U postgres
                  ######################################################
                  # PORT ALREADY IN USE
                  ######################################################
                  # If another `nix-shell` is  running with a PostgreSQL
                  # instance,  the logs  will show  complaints that  the
                  # default port 5432  is already in use.  Edit the line
                  # below with  a different  port number,  uncomment it,
                  # and try again.
                  ######################################################
                  if [[ "$PGPORT" ]]; then
                    sed -i "s|^#port.*$|port = $PGPORT|" $PGDATA/postgresql.conf
                  fi
                  echo "listen_addresses = ${"'"}${"'"}" >> $PGDATA/postgresql.conf
                  echo "unix_socket_directories = '$PGDATA'" >> $PGDATA/postgresql.conf
                  echo "CREATE USER postgres WITH PASSWORD 'postgres' CREATEDB SUPERUSER;" | postgres --single -E postgres
                fi
              '')
              (pkgs.writeShellScriptBin "pg-start" ''
                ## # Postgres Fallback using docker
                ## docker run -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -p 5432:5432 postgres:14

                [ ! -d $PGDATA ] && pg-setup

                ####################################################################
                # Start PostgreSQL
                # ==================================================================
                # Setting all  necessary configuration  options via  `pg_ctl` (which
                # is  basically  a wrapper  around  `postgres`)  instead of  editing
                # `postgresql.conf` directly with `sed`. See docs:
                #
                # + https://www.postgresql.org/docs/current/app-pg-ctl.html
                # + https://www.postgresql.org/docs/current/app-postgres.html
                #
                # See more on the caveats at
                # https://discourse.nixos.org/t/how-to-configure-postgresql-declaratively-nixos-and-non-nixos/4063/1
                # but recapping out of paranoia:
                #
                # > use `SHOW`  commands to  check the  options because  `postgres -C`
                # > "_returns values  from postgresql.conf_" (which is  not changed by
                # > supplying  the  configuration options  on  the  command line)  and
                # > "_it does  not reflect  parameters supplied  when the  cluster was
                # > started._"
                #
                # OPTION SUMMARY
                # --------------------------------------------------------------------
                #
                #  + `unix_socket_directories`
                #
                #    > PostgreSQL  will  attempt  to create  a  pidfile  in
                #    > `/run/postgresql` by default, but it will fail as it
                #    > doesn't exist. By  changing the configuration option
                #    > below, it will get created in $PGDATA.
                #
                #   + `listen_addresses`
                #
                #     > In   tandem  with   edits   in  `pg_hba.conf`   (see
                #     > `HOST_COMMON`  below), it  configures PostgreSQL  to
                #     > allow remote connections (otherwise only `localhost`
                #     > will get  authenticated and the rest  of the traffic
                #     > discarded).
                #     >
                #     > NOTE: the  edit  to  `pga_hba.conf`  needs  to  come
                #     >       **before**  `pg_ctl  start`  (or  the  service
                #     >       needs to be restarted otherwise), because then
                #     >       the changes are not being reloaded.
                #     >
                #     > More info  on setting up and  troubleshooting remote
                #     > PosgreSQL connections (these are  all mirrors of the
                #     > same text; again, paranoia):
                #     >
                #     >   + https://stackoverflow.com/questions/24504680/connect-to-postgres-server-on-google-compute-engine
                #     >   + https://stackoverflow.com/questions/47794979/connecting-to-postgres-server-on-google-compute-engine
                #     >   + https://medium.com/scientific-breakthrough-of-the-afternoon/configure-postgresql-to-allow-remote-connections-af5a1a392a38
                #     >   + https://gist.github.com/toraritte/f8c7fe001365c50294adfe8509080201#file-configure-postgres-to-allow-remote-connection-md
                HOST_COMMON="host\s\+all\s\+all"
                sed -i "s|^$HOST_COMMON.*127.*$|host all all 0.0.0.0/0 trust|" $PGDATA/pg_hba.conf
                sed -i "s|^$HOST_COMMON.*::1.*$|host all all ::/0 trust|"      $PGDATA/pg_hba.conf
                #  + `log*`
                #
                #    > Setting up basic logging,  to see remote connections
                #    > for example.
                #    >
                #    > See the docs for more:
                #    > https://www.postgresql.org/docs/current/runtime-config-logging.html

                pg_ctl                                                  \
                  -D $PGDATA                                            \
                  -l $PGDATA/postgres.log                               \
                  -o "-c unix_socket_directories='$PGDATA'"             \
                  -o "-c listen_addresses='*'"                          \
                  -o "-c log_destination='stderr'"                      \
                  -o "-c logging_collector=on"                          \
                  -o "-c log_directory='log'"                           \
                  -o "-c log_filename='postgresql-%Y-%m-%d_%H%M%S.log'" \
                  -o "-c log_min_messages=info"                         \
                  -o "-c log_min_error_statement=info"                  \
                  -o "-c log_connections=on"                            \
                  start
              '')
              (pkgs.writeShellScriptBin "pg-console" ''
                psql --host $PGDATA -U postgres
              '')

              (pkgs.writeShellScriptBin "pg-mix-setup" ''
                # ####/################################################################
                # # Install Node.js dependencies if not done yet.
                # ####################################################################
                # if test -d "$PWD/assets/" && ! test -d "$PWD/assets/node_modules/"; then
                #   (cd assets && npm install)
                # fi
                ####################################################################
                # If $MIX_HOME doesn't exist, set it up.
                ####################################################################
                if ! test -d $MIX_HOME; then
                  ######################################################
                  # ...  but first,  test whether  there is  a `_backup`
                  # directory. Had issues with  installing Hex on NixOS,
                  # and Hex and  Phoenix can be copied  from there, just
                  # in case.
                  ######################################################
                  if test -d "$PWD/_backup"; then
                    cp -r _backup/.mix .nix-shell/
                  else
                    ######################################################
                    # Install Hex and Phoenix via the network
                    ######################################################
                    yes | ${elixir}/bin/mix local.hex
                    # Install Phoenix
                    # yes | ${elixir}/bin/mix archive.install hex phx_new
                    #TODO:Go to stable whenever it's released
                    yes | ${elixir}/bin/mix archive.install hex phx_new 1.7.0-rc.0
                  fi
                fi
                if test -f "mix.exs"; then
                  # These are not in the  `if` section above, because of
                  # the `hex` install glitch, it  could be that there is
                  # already a `$MIX_HOME` folder. See 2019-08-05_0553
                  ${elixir}/bin/mix deps.get
                  ######################################################
                  # `ecto.setup` is defined in `mix.exs` by default when
                  # Phoenix  project  is  generated via  `mix  phx.new`.
                  # It  does  `ecto.create`,   `ecto.migrate`,  and  run
                  # `priv/seeds`.
                  ######################################################
                  ${elixir}/bin/mix ecto.setup
                fi
              '')

              (pkgs.writeShellScriptBin "update" ''
                nix flake update --commit-lock-file && ${elixir}/bin/mix deps.update --all && ${elixir}/bin/mix deps.get && ${elixir}/bin/mix compile
              '')

              (pkgs.writeShellScriptBin "check-formatted" ''
                cd ${root}

                echo " > CHECKING nix formatting"
                ${pkgs.nixpkgs-fmt}/bin/nixpkgs-fmt *.nix --check
                echo " > CHECKING mix formatting"
                ${elixir}/bin/mix format --check-formatted
              '')
            ]
            ++ pkgs.lib.optional pkgs.stdenv.isLinux pkgs.libnotify # For ExUnit Notifier on Linux.
            ++ pkgs.lib.optional pkgs.stdenv.isLinux pkgs.inotify-tools # For file_system on Linux.
            ++ pkgs.lib.optional pkgs.stdenv.isDarwin pkgs.terminal-notifier # For ExUnit Notifier on macOS.
            ++ pkgs.lib.optionals pkgs.stdenv.isDarwin (
              with pkgs.darwin.apple_sdk.frameworks;
              [
                # For file_system on macOS.
                CoreFoundation
                CoreServices
              ]
            );

          shellHook = ''
            if ! test -d .nix-shell; then
              mkdir .nix-shell
            fi

            export NIX_SHELL_DIR=$PWD/.nix-shell
            # Put the PostgreSQL databases in the project directory.
            export PGDATA=$NIX_SHELL_DIR/db
            # Put any Mix-related data in the project directory.
            export MIX_HOME=$NIX_SHELL_DIR/.mix
            export MIX_ARCHIVES=$MIX_HOME/archives
            export HEX_HOME=$NIX_SHELL_DIR/.hex

            export PATH=$MIX_HOME/bin:$PATH
            export PATH=$HEX_HOME/bin:$PATH
            export PATH=$MIX_HOME/escripts:$PATH
            export LIVEBOOK_HOME=$PWD


            export ERL_LIBS="${erlang}/lib/erlang/lib"

            ${elixir}/bin/mix --version
            ${elixir}/bin/iex --version
          '';
        };
      }
    );
}

mix.exs

defmodule Proj.MixProject do
  use Mix.Project

  def project do
    [
      app: :proj,
      version: "0.1.0",
      elixir: "~> 1.14",
      elixirc_paths: elixirc_paths(Mix.env()),
      start_permanent: Mix.env() == :prod,
      aliases: aliases(),
      deps: deps()
    ]
  end

  # Configuration for the OTP application.
  #
  # Type `mix help compile.app` for more information.
  def application do
    [
      mod: {Proj.Application, []},
      extra_applications: [:logger, :runtime_tools]
    ]
  end

  # Specifies which paths to compile per environment.
  defp elixirc_paths(:test), do: ["lib", "test/support"]
  defp elixirc_paths(_), do: ["lib"]

  # Specifies your project dependencies.
  #
  # Type `mix help deps` for examples and options.
  defp deps do
    [
      {:phoenix, "~> 1.7.21"},
      {:phoenix_ecto, "~> 4.5"},
      {:ecto_sql, "~> 3.10"},
      {:postgrex, ">= 0.0.0"},
      {:phoenix_html, "~> 4.1"},
      {:phoenix_live_reload, "~> 1.2", only: :dev},
      {:phoenix_live_view, "~> 1.0"},
      {:floki, ">= 0.30.0", only: :test},
      {:phoenix_live_dashboard, "~> 0.8.3"},
      {:esbuild, "~> 0.8", runtime: Mix.env() == :dev},
      {:tailwind, "~> 0.2.0", runtime: Mix.env() == :dev},
      {:heroicons,
       github: "tailwindlabs/heroicons",
       tag: "v2.1.1",
       sparse: "optimized",
       app: false,
       compile: false,
       depth: 1},
      {:swoosh, "~> 1.5"},
      {:finch, "~> 0.13"},
      {:telemetry_metrics, "~> 1.0"},
      {:telemetry_poller, "~> 1.0"},
      {:gettext, "~> 0.26"},
      {:jason, "~> 1.2"},
      {:dns_cluster, "~> 0.1.1"},
      {:bandit, "~> 1.5"}
    ]
  end

  # Aliases are shortcuts or tasks specific to the current project.
  # For example, to install project dependencies and perform other setup tasks, run:
  #
  #     $ mix setup
  #
  # See the documentation for `Mix` for more info on aliases.
  defp aliases do
    [
      setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"],
      "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
      "ecto.reset": ["ecto.drop", "ecto.setup"],
      test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"],
      "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"],
      "assets.build": ["tailwind proj", "esbuild proj"],
      "assets.deploy": [
        "tailwind proj --minify",
        "esbuild proj --minify",
        "phx.digest"
      ]
    ]
  end
end

found: GitHub - jurraca/elixir-templates: Nix flake templates for Elixir projects

and tried to add heroicons using fetchfromgithub.

But I still get the error.

nix build

error: builder for '/nix/store/ak4y1ywr69zn2cd6qhddjdycmfr08hq1-heroicons-2.1.1.drv' failed with exit code 1;
       last 8 log lines:
       > Running phase: unpackPhase
       > unpacking source archive /nix/store/190y6mw4h2bm7bk4knj8aycapyzhdrlx-source
       > source root is source
       > Running phase: patchPhase
       > Running phase: updateAutotoolsGnuConfigScriptsPhase
       > Running phase: configurePhase
       > Running phase: buildPhase
       > ** (Mix) Could not find a Mix.Project, please ensure you are running Mix in a directory with a mix.exs file

flake.nix

{
  description = "A flake template for Phoenix 1.7 projects.";

  inputs = {
    # nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
  };

  outputs =
    {
      self,
      nixpkgs,
    }:
    let
      overlay = prev: final: rec {
        # https://github.com/erlang/otp/security/advisories/GHSA-37cp-fgq5-7wc2
        erlang = prev.beam.interpreters.erlang_27.override {
          version = "27.3.3";
          sha256 = "sha256-OTCCfVeJADxKlmgk8rRE3uzY8Y9qYwY/ubiopWG/0ao=";
          odbcSupport = true; # Define Erlang with ODBC support
        };
        beamPackages = prev.beam.packagesWith erlang;
        elixir = beamPackages.elixir_1_18;
        hex = beamPackages.hex;
        final.mix2nix = prev.mix2nix.overrideAttrs {
          nativeBuildInputs = [ final.elixir ];
          buildInputs = [ final.erlang ];
        };
      };

      forAllSystems = nixpkgs.lib.genAttrs [
        "x86_64-linux"
        "aarch64-linux"
        "x86_64-darwin"
        "aarch64-darwin"
      ];

      nixpkgsFor =
        system:
        import nixpkgs {
          inherit system;
          overlays = [ overlay ];
        };
    in
    {
      packages = forAllSystems (
        system:
        let
          pkgs = nixpkgsFor system;
          # FIXME: import the Mix deps into Nix by running `mix2nix > deps.nix` from a dev shell
          # mixNixDeps = import ./deps.nix {
          #   lib = pkgs.lib;
          #   beamPackages = pkgs.beamPackages;
          # };
          mixNixDeps = import ./deps.nix rec {
            lib = pkgs.lib;
            beamPackages = pkgs.beamPackages;
            overrides = (
              final: prev: {
                # mix2nix does not support git dependencies yet,
                # so we need to add them manually
                heroicons = beamPackages.buildMix {
                  name = "heroicons";
                  version = "2.1.1";

                  src = pkgs.fetchFromGitHub {
                    owner = "tailwindlabs";
                    repo = "heroicons";
                    tag = "v2.1.1";
                    hash = "sha256-4yRqfY8r2Ar9Fr45ikD/8jK+H3g4veEHfXa9BorLxXg=";
                  };
                  beamDeps = [ ];
                };
              }
            );
          };
        in
        {
          default = pkgs.beamPackages.mixRelease {
            pname = "my-phx-app";
            # Elixir app source path
            src = ./.;
            version = "0.1.0";
            # FIXME: mixNixDeps was specified in the FIXME above. Uncomment the next line.
            inherit mixNixDeps;

            # add esbuild and tailwindcss
            buildInputs = [
              pkgs.elixir
              pkgs.esbuild
              pkgs.tailwindcss
            ];

            # Explicitly declare tailwind and esbuild binary paths (don't let Mix fetch them)
            preConfigure = ''
              substituteInPlace config/config.exs \
                --replace "config :tailwind," "config :tailwind, path: \"${pkgs.tailwindcss}/bin/tailwindcss\","\
                --replace "config :esbuild," "config :esbuild, path: \"${pkgs.esbuild}/bin/esbuild\", "
            '';

            # Deploy assets before creating release
            preInstall = ''
              # https://github.com/phoenixframework/phoenix/issues/2690
               mix do deps.loadpaths --no-deps-check, assets.deploy
            '';
          };
        }
      );
      devShells = forAllSystems (
        system:
        let
          pkgs = nixpkgsFor system;
        in
        {
          default = self.devShells.${system}.dev;
          dev = pkgs.callPackage ./shell.nix {
            dbName = "db_dev";
            mixEnv = "dev";
          };
          test = pkgs.callPackage ./shell.nix {
            dbName = "db_test";
            mixEnv = "test";
          };
          prod = pkgs.callPackage ./shell.nix {
            dbName = "db_prod";
            mixEnv = "prod";
          };
        }
      );
    };
}

The heroicons github repo you are pointing to in your flake.nix is not a mix project.

I got it working to build the project’s flake with nix build , but once I import the flake in my system’s flake it won’t build and shows an error.
Does anyone know what can fix this?

building '/nix/store/qq3lynasxh1v888qddm1bmhvznny3kal-my_new_project-0.1.0.drv'...
error: builder for '/nix/store/qq3lynasxh1v888qddm1bmhvznny3kal-my_new_project-0.1.0.drv' failed with exit code 1;
       last 25 log lines:
       > ==> websock
       > Compiling 1 file (.ex)
       > Generated websock app
       > ==> bandit
       > Compiling 54 files (.ex)
       > Generated bandit app
       > ==> swoosh
       > Compiling 53 files (.ex)
       > Generated swoosh app
       > ==> websock_adapter
       > Compiling 4 files (.ex)
       > Generated websock_adapter app
       > ==> phoenix
       > Compiling 71 files (.ex)
       > Generated phoenix app
       > ==> phoenix_live_view
       > Compiling 39 files (.ex)
       > Generated phoenix_live_view app
       > ==> phoenix_live_dashboard
       > Compiling 36 files (.ex)
       > Generated phoenix_live_dashboard app
       > ==> phoenix_ecto
       > Compiling 7 files (.ex)
       > Generated phoenix_ecto app
       > ln: failed to create symbolic link 'deps/expo/src': File exists
       For full logs, run:
         nix log /nix/store/qq3lynasxh1v888qddm1bmhvznny3kal-my_new_project-0.1.0.drv

commands:

### Mix
mkdir my_new_project
cd my_new_project
yes | mix archive.install hex phx_new
yes | mix phx.new .

# Modify mix.exs by adding the deps_nix to the dependencies:
sed -i '/:phoenix,/a \ \ \ \ \ \ \{:deps_nix, "~> 2.0", only: :dev},' mix.exs
# Modify mix.exs by adding these aliases:
sed -i '/test:\ /a \ \ \ \ \ \ \"deps.update": ["deps.update", "deps.nix"],' mix.exs
sed -i '/test:\ /a \ \ \ \ \ \ \"deps.get": ["deps.get", "deps.nix"],' mix.exs

mix deps.get # Fetch the deps_nix dependency:
mix deps.nix # Generate the initial deps.nix file
git add . # Make files available to the Nix flake
git commit -m "first"

### Build the project
nix build -L

### Create a local database
DATABASE_URL=ecto://postgres:postgres@localhost/my_new_project \
MIX_ENV=prod \
SECRET_KEY_BASE="$(mix phx.gen.secret)" \
mix ecto.create

### Run the built artifact
DATABASE_URL=ecto://postgres:postgres@localhost/my_new_project \
PHX_SERVER=true \
RELEASE_COOKIE=abbccc \
SECRET_KEY_BASE="$(mix phx.gen.secret)" \
./result/bin/my_new_project start

project flake.nix

{
  description = "A flake template for Phoenix 1.7 projects.";

  inputs = {
    nixpkgs.url = "flake:nixpkgs/nixos-unstable";
  };

  outputs =
    { self, nixpkgs }:
    let
      overlay = prev: final: rec {
        erlang = prev.beam.interpreters.erlang_27.override {
          odbcSupport = true;
        };
        beamPackages = prev.beam.packagesWith erlang;
        elixir = beamPackages.elixir_1_18;
        hex = beamPackages.hex;
        final.mix2nix = prev.mix2nix.overrideAttrs {
          nativeBuildInputs = [ final.elixir ];
          buildInputs = [ final.erlang ];
        };
      };

      forAllSystems =
        generate:
        nixpkgs.lib.genAttrs
          [
            "aarch64-darwin"
            "aarch64-linux"
            "x86_64-darwin"
            "x86_64-linux"
          ]
          (
            system:
            generate {
              pkgs = import nixpkgs {
                inherit system;
                overlays = [ overlay ];
              };
            }
          );

      src = ./.;
      mixExs = builtins.readFile "${src}/mix.exs";
      pname = builtins.head (builtins.match ".*app:[[:space:]]*:([a-zA-Z0-9_]+).*" mixExs);
      version = builtins.head (
        builtins.match ".*version:[[:space:]]*\"([0-9]+\\.[0-9]+\\.[0-9]+)\".*" mixExs
      );
    in
    {
      packages = forAllSystems (
        { pkgs, ... }:
        let
          mixNixDeps = pkgs.callPackages ./deps.nix { };
        in
        rec {
          default =
            with pkgs;
            beamPackages.mixRelease {
              inherit pname version src;
              inherit mixNixDeps;

              DATABASE_URL = "";
              SECRET_KEY_BASE = "";

              postBuild = ''
                tailwind_path="$(mix do \
                  app.config --no-deps-check --no-compile, \
                  eval 'Tailwind.bin_path() |> IO.puts()')"
                esbuild_path="$(mix do \
                  app.config --no-deps-check --no-compile, \
                  eval 'Esbuild.bin_path() |> IO.puts()')"

                ln -sfv ${tailwindcss}/bin/tailwindcss "$tailwind_path"
                # ln -sfv ${tailwindcss_4}/bin/tailwindcss "$tailwind_path"
                ln -sfv ${esbuild}/bin/esbuild "$esbuild_path"
                ln -sfv ${mixNixDeps.heroicons} deps/heroicons

                mix do \
                  app.config --no-deps-check --no-compile, \
                  assets.deploy --no-deps-check
              '';
            };

          nixosModule = { config, lib, pkgs, ... }: {
              config = {
                environment.systemPackages = with pkgs; [
                  default
                ];
              };
            };
        }
      );

      devShells = forAllSystems (
        { pkgs, ... }:
        {
          default = self.devShells.${pkgs.system}.dev;
          dev = pkgs.callPackage ./shell.nix {
            dbName = "db_dev";
            mixEnv = "dev";
          };
          test = pkgs.callPackage ./shell.nix {
            dbName = "db_test";
            mixEnv = "test";
          };
          prod = pkgs.callPackage ./shell.nix {
            dbName = "db_prod";
            mixEnv = "prod";
          };
        }
      );
    };
}

project shell.nix

{
  pkgs,
  dbName,
  mixEnv,
  beamPackages,
}:
let
  # define packages to install
  basePackages = with pkgs; [
    git
    elixir
    elixir-ls
    hex
    mix2nix
    postgresql
    # pgcli
    esbuild
    nodePackages.prettier # Formatting .js file
    tailwindcss
    # tailwindcss_4
    # codespell --skip="./deps/*,./.git/*,./assets/*,./erl_crash.dump" -w
    codespell
    # dot -Tpng ecto_erd.dot -o erd.png
    pkgs.graphviz

    unixODBC
  ];

  # Add basePackages + optional system packages per system
  inputs =
    with pkgs;
    basePackages
    ++ lib.optionals stdenv.isLinux [ libnotify ] # For ExUnit Notifier on Linux.
    ++ lib.optionals stdenv.isLinux [ inotify-tools ] # For file_system on Linux.
    ++ lib.optionals stdenv.isDarwin [ terminal-notifier ] # For ExUnit Notifier on macOS.
    ++ lib.optionals stdenv.isDarwin (
      with darwin.apple_sdk.frameworks;
      [
        # For file_system on macOS.
        CoreFoundation
        CoreServices
      ]
    );

  # define shell startup command
  hooks = ''
    # this allows mix to work on the local directory
    mkdir -p .nix-mix .nix-hex
    export MIX_HOME=$PWD/.nix-mix
    export HEX_HOME=$PWD/.nix-mix
    export PATH=$MIX_HOME/bin:$HEX_HOME/bin:$PATH

    export MIX_ENV=${mixEnv}

    export LANG=en_US.UTF-8
    # keep your shell history in iex
    export ERL_AFLAGS="-kernel shell_history enabled"

    # postgres related
    # keep all your db data in a folder inside the project
    export PGDATA="$PWD/db"

    # phoenix related env vars
    export POOL_SIZE=15
    export DB_URL="postgresql://postgres:postgres@localhost:5432/${dbName}"
    export PORT=4000
  '';
in
pkgs.mkShell {
  buildInputs = inputs;
  shellHook = hooks;
}

system flake.nix

{
  inputs = {
    nixpkgs.url = "flake:nixpkgs/nixos-unstable";
    my_new_project.url = "path:/mypath/my_new_project";
    # ...
  };

  # ...
}

a system module

{
  config,
  lib,
  inputs,
  ...
}:
with lib;
{
  imports = [
    inputs.my_new_project.outputs.packages.x86_64-linux.nixosModule
  ];

  config = {
  }
}

inspiration: GitHub - code-supply/nix-phoenix: Tools and instructions for starting Phoenix projects with Nix deployment

I think the problem is that the deps folder already exists in the build environment. This could be because the flake.nix has src = ./.;. This is a great resource for learning how to include only the files you need in the build environment: Working with local files — nix.dev documentation

To clarify a bit more, buildMix is going to get all the deps and compile them again based on the deps.nix contents.

1 Like
      src = pkgs.nix-gitignore.gitignoreSource [
        "/flake.nix"
        "/flake.lock"
        "/shell.nix"
        "/README.md"
        "/.git/"
        # TODO: add extra patterns besides ones specified by .gitignore, such as /fly.toml
      ] ./.;

Now it nixos-rebuild without errors.

I just have to check the systemd service.

Thank you.

Good to hear you got it working! I have gone through a similar process myself :wink:.

I implemented the SystemD services in three different parts for deployment on my server :

  • seed service (runs only once),
  • migration service (runs after the seed service and is restarted whenever the web service is restarted),
  • web service (depends on the migration service to exits successfully).

If you want, you can structure the NixOS module in a way that you setup the database, nginx proxy, ssl from one configuration.

In my NixOS config I can do the following:

services.my-project = {
  prod = {
    branch = "master";
    commit = "commit ref";
    port = "4000";
    host = "example.com";
  };
  staging = {
    branch = "dev";
    commit = "commit ref on dev branch";
    port = "4001";
    host = "staging.example.com";
  };
};

Any branch can be used to create a domain name with minimal configuration effort. Database names are just the name of the app with the branch as a suffix (maybe I will allow a explicit suffix in the config in the future). Having to specify the port is something I want to get rid of and just use Unix sockets instead. Using Systemd socket should allow for zero-downtime deployments, but there is still work to be done with regard to gen_tcp.

If you want to see how it works, I can post the aforementioned module in this thread.

1 Like

Please do share.

I am a bit short on time right now, but will try to post it this weekend.

Please do! This should be plug & play – but it’s next to impossible! :joy:

Got it to work, finally. Little knowledge dump here – still room for improvement. Comes with a little helper script to connect to the running node (if enabled in the module).

heroicons as Mix dep is problematic: 1. comes in via git, 2. is not a Mix package

Moved it to npm as per Phoenix 1.7 with npm · Latinum.

If you don’t want to introduce a Release module, you can also eval app start and repo migration directly in the startup script (or do it manually).

Final package.nix

{
  lib,
  beamPackages,
  nodejs,
  makeWrapper,
  fetchgit,
  buildNpmPackage,
}: let
  pname = "senegal-phoenix";
  version = "0.1.0";

  src = fetchgit {
    url = "https://git.example.com/self/senegal-phoenix.git";
    rev = "07ec6af047c5df2ed97f3d806070dd4ce1a46fd8";
    sha256 = "sha256-id2oxX8a5wgO/AhnZGigZ7AzFBZ+mhTptPeCxlYpOFk=";
  };

  # Build assets with npm
  assets = buildNpmPackage {
    pname = "${pname}-assets";
    inherit version src;
    sourceRoot = "${src.name}/assets";
    npmDepsHash = "sha256-D8C4ra2RYbHQdv4lILpnAra0+TBszh+bAW9iqUTfWBQ=";
    dontNpmBuild = true;
    installPhase = ''
      runHook preInstall
      cp -r . $out/
      runHook postInstall
    '';
  };

  # Fetch Mix dependencies using beamPackages.fetchMixDeps
  mixFodDeps = beamPackages.fetchMixDeps {
    pname = "${pname}-mix-deps";
    inherit src version;
    sha256 = "sha256-AzGYVTcESiTVmhDM3lSdg9EWzAe8+aBkerqcwCR2xQY=";
  };
in
  beamPackages.mixRelease {
    inherit pname version src mixFodDeps;

    nativeBuildInputs = [
      makeWrapper
      nodejs
    ];

    # Environment for production build
    MIX_ENV = "prod";

    # Clean up any existing build artifacts before Nix creates symlinks
    preConfigure = ''
      rm -rf deps _build assets
    '';

    # Setup pre-built assets before Mix compile
    preBuild = ''
      # Copy pre-built assets from npm package
      cp --no-preserve=mode -r ${assets} assets
      # Fix esbuild executable permissions
      chmod +x assets/node_modules/@esbuild/*/bin/esbuild 2>/dev/null || true
      chmod +x assets/node_modules/.bin/* 2>/dev/null || true
    '';

    # Build the release with assets
    postBuild = ''
      # Deploy Phoenix assets
      mix do deps.loadpaths --no-deps-check, assets.deploy
      mix do deps.loadpaths --no-deps-check, phx.digest priv/static
    '';

    # Install and create wrapper
    postInstall = ''
      # Create a wrapper script for easier execution
      mkdir -p $out/bin
      makeWrapper $out/bin/senegal $out/bin/senegal-server \
        --set MIX_ENV prod \
        --set PHX_SERVER true
    '';

    passthru = {
      inherit assets;
    };

    meta = with lib; {
      description = "Phoenix LiveView application packaging example";
      homepage = "https://github.com/your-org/senegal-phoenix";
      license = licenses.mit;
      maintainers = [];
      platforms = platforms.unix;
    };
  }

Final module.nix

{
  config,
  lib,
  pkgs,
  ...
}: let
  cfg = config.services.senegal-phoenix;
  senegalPhoenix = pkgs.callPackage ../pkgs/senegal-phoenix.nix {};
in {
  options.services.senegal-phoenix = {
    enable = lib.mkEnableOption "Senegal Phoenix - Example App";

    package = lib.mkOption {
      type = lib.types.package;
      default = senegalPhoenix;
      description = "The Senegal Phoenix package to use";
    };

    port = lib.mkOption {
      type = lib.types.port;
      default = 4000;
      description = "Port to listen on";
    };

    host = lib.mkOption {
      type = lib.types.str;
      default = "localhost";
      description = "The host to configure the router generation from";
    };

    environmentFile = lib.mkOption {
      type = lib.types.nullOr lib.types.path;
      default = null;
      description = ''
        Path to environment file containing configuration variables (sourced by systemd).
        Example file contents:
          DATABASE_URL=postgresql://user:pass@host/db
          SECRET_KEY_BASE=your-secret-key-here
          # MUST be set
          RELEASE_COOKIE=your-erlang-cookie-here
      '';
    };

    secretKeyBaseFile = lib.mkOption {
      type = lib.types.nullOr lib.types.path;
      default = null;
      description = "A file containing the Phoenix Secret Key Base";
    };

    databaseUrlFile = lib.mkOption {
      type = lib.types.nullOr lib.types.path;
      default = null;
      description = "A file containing the URL to connect to the database";
    };

    openAIKeyFile = lib.mkOption {
      type = lib.types.nullOr lib.types.path;
      default = null;
      description = "A file containing the OpenAI API key";
    };

    enableDistribution = lib.mkOption {
      type = lib.types.bool;
      default = false;
      description = ''
        Enable Erlang distribution for remote shell access and clustering.
        Requires RELEASE_COOKIE to be set in environmentFile for authentication.
        Provides 'senegal-connect' command for connecting to the running node.
      '';
    };

    user = lib.mkOption {
      type = lib.types.str;
      default = "senegal-phoenix";
      description = "User account under which the service runs";
    };

    group = lib.mkOption {
      type = lib.types.str;
      default = "senegal-phoenix";
      description = "Group account under which the service runs";
    };

    dataDir = lib.mkOption {
      type = lib.types.path;
      default = "/var/lib/senegal-phoenix";
      description = "Directory to store application data";
    };
  };

  config = lib.mkIf cfg.enable {
    users.users.${cfg.user} = {
      description = "Senegal Phoenix service user";
      group = cfg.group;
      home = cfg.dataDir;
      createHome = true;
      isSystemUser = true;
    };

    users.groups.${cfg.group} = {};

    systemd.services.senegal-phoenix = {
      description = "Senegal Phoenix - Child sponsorship management system";
      after = ["network.target"];
      wantedBy = ["multi-user.target"];

      serviceConfig = {
        Type = "exec";
        User = cfg.user;
        Group = cfg.group;
        WorkingDirectory = cfg.dataDir;
        Restart = "on-failure";
        RestartSec = "5s";

        # Environment file injection
        EnvironmentFile = lib.mkIf (cfg.environmentFile != null) cfg.environmentFile;

        # Security hardening
        NoNewPrivileges = true;
        PrivateTmp = true;
        ProtectSystem = "strict";
        ProtectHome = true;
        ReadWritePaths = [cfg.dataDir];

        # Load credentials securely
        LoadCredential = lib.filter (x: x != null) [
          (lib.optionalString (cfg.secretKeyBaseFile != null) "SECRET_KEY_BASE:${cfg.secretKeyBaseFile}")
          (lib.optionalString (cfg.databaseUrlFile != null) "DATABASE_URL:${cfg.databaseUrlFile}")
          (lib.optionalString (cfg.openAIKeyFile != null) "OPENAI_API_KEY:${cfg.openAIKeyFile}")
        ];
      };

      script = let
        credentialSetup = ''
          # Load individual credential files
          ${lib.optionalString (cfg.secretKeyBaseFile != null) ''
            export SECRET_KEY_BASE="$(< $CREDENTIALS_DIRECTORY/SECRET_KEY_BASE)"
          ''}
          ${lib.optionalString (cfg.databaseUrlFile != null) ''
            export DATABASE_URL="$(< $CREDENTIALS_DIRECTORY/DATABASE_URL)"
          ''}
          ${lib.optionalString (cfg.openAIKeyFile != null) ''
            export OPENAI_API_KEY="$(< $CREDENTIALS_DIRECTORY/OPENAI_API_KEY)"
          ''}
        '';
      in ''
        ${credentialSetup}

        # Set node name with actual hostname for distribution
        ${lib.optionalString cfg.enableDistribution ''
          export RELEASE_NODE="senegal@$(hostname)"
        ''}

        # Run database migrations
        ${cfg.package}/bin/senegal eval "Senegal.Release.migrate()"

        # Start the Phoenix server
        ${cfg.package}/bin/senegal-server start
      '';

      environment = {
        # Phoenix configuration
        PHX_HOST = cfg.host;
        PORT = toString cfg.port;

        # Elixir/OTP configuration
        RELEASE_DISTRIBUTION =
          if cfg.enableDistribution
          then "sname"
          else "none";
        RELEASE_NODE = "senegal@localhost";

        # Application-specific defaults
        ECTO_IPV6 = "false";
      };
    };

    # Script for connecting to the BEAM node
    environment.systemPackages = lib.mkIf cfg.enableDistribution [
      (pkgs.writeShellScriptBin "senegal-connect" (let
        connectCommand = ''
          cd ${cfg.dataDir}
          ${lib.optionalString (cfg.environmentFile != null) ''
            set -a
            source ${cfg.environmentFile}
            set +a
          ''}
          exec ${cfg.package}/bin/senegal remote
        '';
      in ''
        if [ "$(whoami)" = "${cfg.user}" ]; then
          ${connectCommand}
        elif [ "$(id -u)" = "0" ]; then
          exec su -m ${cfg.user} -c "${connectCommand}"
        else
          echo "Must run as root or ${cfg.user} user. Try: sudo senegal-connect"
          exit 1
        fi
      ''))
    ];
  };
}

release.ex

defmodule Senegal.Release do
  @moduledoc """
  Used for executing DB release tasks when run in production without Mix installed.
  """
  @app :senegal

  def migrate do
    load_app()

    for repo <- repos() do
      {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
    end
  end

  def rollback(repo, version) do
    load_app()
    {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
  end

  defp repos do
    Application.fetch_env!(@app, :ecto_repos)
  end

  defp load_app do
    Application.load(@app)
  end
end

Automatic notes (careful, might be inaccurate and not reflect latest changes to above files):

Nix Package Development Notes

Objective

Transform the Senegal Phoenix Elixir/Phoenix application into a Nix package for reproducible builds and deployment.

Approaches Tried

1. beamPackages.fetchMixDeps (Initial Attempt)

Status: Failed due to Git dependency issues

Method:

  • Used beamPackages.fetchMixDeps to fetch all Mix dependencies
  • Used beamPackages.mixRelease for final package creation

Issues Encountered:

  • Git Dependencies: fetchMixDeps doesn’t handle Git dependencies well
  • Heroicons Dependency: The heroicons dependency from GitHub caused lock mismatches
  • Symlink Errors: Initial “ln: failed to create symbolic link ‘./deps’: File exists” errors

Key Fix:

  • Added preConfigure = ''rm -rf deps _build'' to clean build artifacts before Nix creates symlinks
  • Simplified heroicons dependency in mix.exs by removing sparse: "optimized" and depth: 1 options

Final Error: Lock mismatch for heroicons Git dependency persisted despite hash updates

2. mix2nix Approach (Current)

Status: In progress, still has dependency lock issues

Method:

  • Generate mix.nix file using mix2nix tool
  • Handle Git dependencies manually (heroicons via fetchFromGitHub)
  • Use mixNixDeps instead of mixFodDeps

Implementation:

# Heroicons Git dependency (mix2nix doesn't support Git deps)
heroicons = fetchFromGitHub {
  owner = "tailwindlabs";
  repo = "heroicons";
  rev = "v2.1.1";
  hash = "sha256-4yRqfY8r2Ar9Fr45ikD/8jK+H3g4veEHfXa9BorLxXg=";
};

# Mix dependencies from generated mix.nix
mixNixDeps = import ./mix.nix {
  inherit lib beamPackages;
};

Current Issues:

  • Multiple dependency lock mismatches (finch, openai_ex, websock, bandit, swoosh, etc.)
  • mix.nix generated dependencies don’t match mix.lock versions
  • Need --no-deps-check flag but still getting validation errors

Key Learnings

Git Dependencies in Nix

  • beamPackages.fetchMixDeps has poor support for Git dependencies
  • Git dependencies need manual handling via fetchFromGitHub
  • Mix dependency specifications with sparse and depth options confuse Nix tools

Source Filtering

  • Using src = ./. can include unwanted build artifacts
  • lib.cleanSource helps filter but may not be sufficient
  • Proper .gitignore patterns are crucial for clean Nix builds

Mix Lock File Synchronization

  • Nix tools expect exact version matches between mix.lock and generated dependency files
  • Local development changes to dependencies can break Nix builds
  • --no-deps-check flag needed to bypass validation in Nix context

Resources Referenced

3. beamPackages.fetchMixDeps (Second Attempt - SUCCESS!)

Status: :white_check_mark: SUCCESS! Working Nix package

Key Changes:

  • Removed Git Dependencies: Cleaned heroicons Git dependency from mix.lock using mix deps.clean --unused --unlock
  • Switched to npm heroicons: Now using heroicons via npm package in assets/package.json instead of Git
  • Simplified Asset Handling: Build assets as part of main build process, not separately
  • Correct Wrapper Path: Fixed makeWrapper to use correct executable path

Implementation:

# Uses beamPackages.fetchMixDeps for Mix dependencies
mixFodDeps = beamPackages.fetchMixDeps {
  pname = "${pname}-mix-deps";
  src = lib.cleanSource ./.;
  version = version;
  sha256 = "sha256-vtNELuwL0S1jyfpAKp+Z/356jf8Jq4Lx9eF69c/EYyI=";
  mixEnv = "prod";
};

# Assets built during main build process
preBuild = ''
  cd assets
  npm install
  cd ..
'';

postBuild = ''
  mix assets.deploy
'';

Success Factors:

  1. No Git Dependencies: All dependencies are now from Hex registry
  2. Clean mix.lock: Regenerated after removing Git dependencies
  3. npm heroicons: Switched from Git to npm package for heroicons
  4. Integrated Asset Build: Assets built within Phoenix build context, not separately

Nice to get it working!

I was doing some refactoring last night to make my script a bit more modular. The progress is on GitHub - Zurga/phoenix_nix

2 Likes

I updated the repo with some documentation.

1 Like