Published on
-6 min read

Rust Environment and Docker Build with Nix Flakes

Table of Contents

Why Nix

Getting a dev environment setup with rust is usually pretty simple, just use rustup then you're good to go. Using a build tool like Nix can buy you much more for not much extra work. Nix lets you

  • Specify non rust project dependencies in code
  • Automatically add all your projects tools/dependencies to your path with direnv
  • Easily build slim docker containers

Once you start working in a repo with nix you never want to go back. No more READMEs with a list of Homebrew, apt, pacman, etc. commands you need to run. Building slim docker containers is a breeze without needing to manually handle multiple layers to copy build artifacts from.

This post will mostly be a quick and dirty guide to getting started with nix, so I won't go into too much detail on what nix is doing under the hood/nix syntax. For a quick and dirty nix syntax reference I recommend learn X in Y's post, if you have some functional programming experience most of the basics will be quick to pick up.

The Dev Environment

We will use nix flakes to set up nix for our project. Flakes are nix's newish way to make nix builds more reproducible by adding a lock file concept to the project. Each flake can have inputs which are other flakes/nix files and many outputs. One thing to note, all files referenced in your flake (including itself) must be added to git. If you run into any file not found errors make sure you git add everything you need.

To get started in the root of your project run

$ nix flake init

This will give you a flake.nix file that looks like

flake.nix
{
  description = "A very basic flake";

  outputs = { self, nixpkgs }: {

    packages.x86_64-linux.hello = nixpkgs.legacyPackages.x86_64-linux.hello;

    defaultPackage.x86_64-linux = self.packages.x86_64-linux.hello;

  };
}

This starter flake will build a hello world binary with nix build .#hello which calls the first line or with just nix build to call the defaultPackage line. The downside is this only builds the package on x86/64 Linux, let's add some inputs to generalize this to more systems.

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

  outputs = { self, nixpkgs, flake-utils, ... }:
    flake-utils.lib.eachDefaultSystem (system:
      let pkgs = import nixpkgs { inherit system; };
      in {
        packages.hello = pkgs.hello;
        defaultPackage = pkgs.hello;
      });
}

We added two inputs, the first is nixpkgs which lets us specify which version of nixpkgs we should use. There are many thousands of packages in the nixpkg repository, and they are updated often so here will use the unstable branch. We also added flake-utils which helps us generalize the flake to support multiple systems, not just Linux.

Now on Linux and mac the hello package will build. When you run nix build, you should see a result folder which contains the hello package, you can run it with ./result/bin/hello. The result folder is a symlink to the output of build in the nix store (where nix keeps all outputs). It will not always be a folder, it will just depend on the build.

Rust in Nix

To move on from "hello world" to rust lets add another input

flake.nix
inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
    flake-utils.url = "github:numtide/flake-utils";
    rust-overlay.url = "github:oxalica/rust-overlay";
  };

outputs = { self, nixpkgs, flake-utils, rust-overlay, ... }:
  flake-utils.lib.eachDefaultSystem (system:
    let
      overlays = [ (import rust-overlay) ];
      pkgs = import nixpkgs { inherit system overlays; };
      rustVersion = pkgs.rust-bin.stable.latest.default;
    in {
      devShell = pkgs.mkShell {
        buildInputs =
          [ (rustVersion.override { extensions = [ "rust-src" ]; }) ];
      };
    });

We added rust-overlay, so we can easily specify different rust versions without relying on nixpkgs to give us what ever rust version in there.

We also switched the outputs to only have devShell, that output is tied to nix develop, when run you will get a new sandboxed shell with the stable rust version.

If you want to use a specific version/nightly build you can use rustVersion = (pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml); to read a rust toolchain file and use the version specified in there.

You may also have noticed we added .override { extensions = [ "rust-src" ]; }). This is needed for rust analyzer to get rust source code.

Automatically Load the Nix Environment

Now that we have the rust version we want let's make the nix develop step automatic.

Install direnv and nix-direnv. The second is optional but helps with caching, so I recommend it.

Direnv will add hooks to your shell so when you cd into your project it will autoload the nix environment for you without needing to run nix develop.

In the root of your project run

$ echo "use flake" >> .envrc
$ direnv allow

The .envrc file will be loaded by direnv, and it will use the flake's devShell output to set up your environment. On changes to your flake direnv will reload only what has changed.

If you are using VS Code, use nix env selector, so VS Code is aware of the flake. It is not always necessary if you open VS Code from your terminal, but It's simple to set up.

Build Rust Project

Now that we have rust in our dev environment we can make a new rust app with

$ cargo init

Then we can run/build the project like you normally would with cargo run/cargo build. That works well while developing but let's use nix to build the project, this will help us later on when we make the docker image.

Let's update the outputs too

flake.nix
outputs = { self, nixpkgs, flake-utils, rust-overlay, ... }:
    flake-utils.lib.eachDefaultSystem (system:
      let
        overlays = [ (import rust-overlay) ];
        pkgs = import nixpkgs { inherit system overlays; };
        rustVersion = pkgs.rust-bin.stable.latest.default;

        rustPlatform = pkgs.makeRustPlatform {
          cargo = rustVersion;
          rustc = rustVersion;
        };

        myRustBuild = rustPlatform.buildRustPackage {
          pname =
            "rust_nix_blog"; # make this what ever your cargo.toml package.name is
          version = "0.1.0";
          src = ./.; # the folder with the cargo.toml

          cargoLock.lockFile = ./Cargo.lock;
        };

      in {
        defaultPackage = myRustBuild;
        devShell = pkgs.mkShell {
          buildInputs =
            [ (rustVersion.override { extensions = [ "rust-src" ]; }) ];
        };
      });

First we have to make a rustPlatform with our rust version. The platform will let us build our rust package with rustPlatform.buildRustPackage. This is the nix equivalent of cargo build. We need cargoLock.lockFile so nix can cache all of your project's dependencies based on your existing lock file.

Now we can run nix build, then your project will be in the result folder again. In my case I can run ./result/bin/rust_nix_blog.

Make a Docker Image

Now that we have nix building the rust project making the docker container is quite easy.

flake.nix
outputs = { self, nixpkgs, flake-utils, rust-overlay, ... }:
    flake-utils.lib.eachDefaultSystem (system:
      let
        overlays = [ (import rust-overlay) ];
        pkgs = import nixpkgs { inherit system overlays; };
        rustVersion = pkgs.rust-bin.stable.latest.default;

        rustPlatform = pkgs.makeRustPlatform {
          cargo = rustVersion;
          rustc = rustVersion;
        };

        myRustBuild = rustPlatform.buildRustPackage {
          pname =
            "rust_nix_blog"; # make this what ever your cargo.toml package.name is
          version = "0.1.0";
          src = ./.; # the folder with the cargo.toml

          cargoLock.lockFile = ./Cargo.lock;
        };

        dockerImage = pkgs.dockerTools.buildImage {
          name = "rust-nix-blog";
          config = { Cmd = [ "${myRustBuild}/bin/rust_nix_blog" ]; };
        };

      in {
        packages = {
          rustPackage = myRustBuild;
          docker = dockerImage;
        };
        defaultPackage = dockerImage;
        devShell = pkgs.mkShell {
          buildInputs =
            [ (rustVersion.override { extensions = [ "rust-src" ]; }) ];
        };
      });

Now nix build or nix build .#docker will build the docker image. After building result is just a sym link to the image tar file of a folder like before. Since nix is declarative it does not load it directly into docker for you. You can load it with

$ docker load < result

You should see an output like rust-nix-blog:yyc9gd4nkydrikzpsvlp3gmwnpxhh1ik which is the image and tag loaded in.

Now run the image with

$ docker run rust-nix-blog:yyc9gd4nkydrikzpsvlp3gmwnpxhh1ik

We can automate this a bit with a script like. (Slightly modified example from here)

#!/usr/bin/env bash
set -e; set -o pipefail;

nix build '.#docker'
image=$((docker load < result) | sed -n '$s/^Loaded image: //p')
docker image tag "$image" rust-nix-blog:latest

Let's use dive to look at the image. You can use it temporarily with nix shell nixpkgs#dive.

 33 MB  └── nix
 33 MB      └── store
374 kB          ├─⊕ h4isr1pyv1fjdrxj2g80vsa4hp2hp76s-rust_nix_blog-0.1.0
1.8 MB          ├─⊕ ik4qlj53grwmg7avzrfrn34bjf6a30ch-libunistring-1.0
246 kB          ├─⊕ w3zngkrag7vnm7v1q8vnqb71q6a1w8gn-libidn2-2.3.2
 30 MB          └─⊕ ybkkrhdwdj227kr20vk8qnzqnmj7a06x-glibc-2.34-115

Looking at the output you can see it's a single layer image with just what is needed to run the binary (in case just libc and the rust code).

Common troubleshooting issues

Non Rust Build Dependencies

Building with nix is great once its working, it will stay working forever. Getting to a working state can be a bit of pain sometimes. If your rust code relies on system packages (like OpenSSL) make sure you include them in buildInputs, for example

rustPlatform.buildRustPackage {
  pname =
    "rust_nix_blog"; # make this what ever your cargo.toml package.name is
  version = "0.1.0";
  src = ./.; # the folder with the cargo.toml
  nativeBuildInputs = [pkgs.pkg-config ]; # just for the host building the package
  buildInputs = [pkgs.openssl]; # packages needed by the consumer
  cargoLock.lockFile = ./Cargo.lock;
};

For OpenSSL specifically I would recommend using rustls when possible. It's easier to build and in rust.

Nix Docs

Nix documentation is not the best. While the wiki and manual have some info, I've found the best resources have been the many nix bloggers out there. Here are some good references

  • Xe most of her blogs relate to nixos, the Linux distro based on nix, but it helps to learn the language and common patterns
  • Ian Henry's how to learn nix Ian has a blog series of him learning nix. It's not really documentation but more curated notes of his process of using nix.
  • Intro to nix flakes Great 3 part series on what flakes are and how to use them
  • Building docker containers with nix Linked this earlier, but it's a good reference for more options on building containers

I also highly recommend on diving headfirst into nix by using Nixos as your Linux distro. It will help make you more comfortable with the language and its great to use once it clicks.