Skip to main content

VS Code Remote Containers with Nix

Nix already provides a neat way to create a development environment with strong guarantees in terms of reproducibility. However, it still requires developers to install it on their computer which, even though Nix doesn’t install anything in directories such as /usr, can be met with some resistance.

On the other hand, VS Code’s devcontainers allow a developer to start working on a project without too much hassle granted they use VS Code and have Docker running. They also might feel better about things running in containers and not polluting their host system.

Here, I’ll describe how to bring these two solutions together and get the best of both worlds. One can use Nix directly on their host system or use VS Code and develop inside a container with the Nix environment.

This is the setup I have been using for a few months now on some projects. Initially, I was concerned that the overhead of a container might become a problem but it turned out that fear was unfounded.


Nix environment

This setup is using Nix Flakes to create a development environment. The entrypoint is in flake.nix at the root of the project.

flake.nix
{
  description = "Development environment for yourproject";

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

  outputs = { self, nixpkgs, flake-utils }:
    flake-utils.lib.eachDefaultSystem (system: {
      devShell = import ./nix/shell.nix {
        pkgs = nixpkgs.legacyPackages.${system};
      };
    });
}

The flake exports a devShell which is defined in nix/shell.nix as follows. This is just a dummy development environment with the Haskell compiler for the sake of demonstration.

nix/shell.nix
{ pkgs }:

pkgs.mkShell {
  buildInputs = with pkgs; [
    ghc
  ];
}

Running nix flake upgrade for the first time will create a new file flake.lock with pinned versions of Nixpkgs and flake-utils. This file should be version-controlled with the rest of the project.

These basic files are already useful for anyone willing to run Nix on the host rather than inside a container. The command nix develop drops you into a shell with this Nix development environment.


Docker image

We now need a Docker image with Nix and some more configuration to automatically load the development environment when started.

Since this relies on Nix Flakes, we need Nix 2.4 (prerelease as of this writing) instead of the latest "official" version.

The Dockerfile is in .devcontainer/ together with the configuration for Remote Containers since it’s specific to this usage.

.devcontainer/Dockerfile
# Reuse a base image made for devcontainers.
FROM mcr.microsoft.com/vscode/devcontainers/base:buster

# These dependencies are required by Nix.
RUN apt update -y
RUN apt -y install --no-install-recommends curl xz-utils

# Install Nix 2.4pre20210126.
ARG NIX_INSTALL_SCRIPT=https://github.com/numtide/nix-flakes-installer/releases/download/nix-2.4pre20210126_f15f0b8/install
RUN curl -L ${NIX_INSTALL_SCRIPT} | sudo -u vscode NIX_INSTALLER_NO_MODIFY_PROFILE=1 sh

# Configuration for Nix from the repository shared amongst developers.
RUN mkdir -p /etc/nix
COPY nix.conf /etc/nix/nix.conf

# This loads the development environment when the container is started.
COPY profile.sh /etc/profile.d/devcontainer.sh

ENV USER=vscode

# Rebuilding the container should not throw the Nix store away so we make /nix
# a volume. See `devcontainer.json` later.
VOLUME /nix

This Dockerfile copies .devcontainer/nix.conf inside the container. It’s used to set some options that are needed to make Nix 2.4 work but it’s also a convenient way to share configuration amongst developers. For example, you could set substituters and trusted-public-keys to add some Nix cache.

.devcontainer/nix.conf
sandbox = false
experimental-features = nix-command flakes ca-references

Finally, .devcontainer/profile.sh loads the Nix development environment as follows.

.devcontainer/profile.sh
#!/bin/bash

PROJECT_DIR=${PROJECT_DIR:-/workspace}

# Make Nix available as it's not installed system-wide.
if [ -e $HOME/.nix-profile/etc/profile.d/nix.sh ] ; then
   . $HOME/.nix-profile/etc/profile.d/nix.sh
fi

# Only load the development environment if this is a login shell so calling a
# shell later on doesn't reload the environment again.
if shopt -q login_shell; then
  pushd "${PROJECT_DIR}"
  eval "$(nix print-dev-env --profile "${PROJECT_DIR}/.devcontainer/.profile")"
  popd
fi

Don’t forget to make this file executable by running the following command.

chmod +x .devcontainer/profile.sh

The file .devcontainer/.profile can be added to .gitignore or equivalent. It is only there to make sure the development environment is not garbage collected if you run nix store gc.


Configuration for the container

The final step is to tell VS Code how to load the devcontainer. This is achieved by adding a configuration at .devcontainer/devcontainer.json.

.devcontainer/devcontainer.json
{
  "name": "Your project",

  "build": {
    "dockerfile": "Dockerfile",
  },

  "mounts": [
    "source=yourproject_nix,target=/nix,type=volume",
  ],

  "containerEnv": {
    "PROJECT_DIR": "${containerWorkspaceFolder}"
  },

  "settings": {
    "terminal.integrated.shell.linux": "/bin/bash"
  },

  "userEnvProbe": "loginShell",

  "extensions": [
    "bbenoist.nix",
  ],

  "forwardPorts": [],

  "remoteUser": "vscode",
}

A volume called yourproject_nix will be created and mounted at /nix. This implies that you won’t need to recompile all Nix derivations after rebuilding the container.

In this example, the VS Code’s extension bbenoist.nix is automatically installed in the container. You might want to add other extensions specific to your project’s tech stack.