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.