Creating an isolated and reproducible development environment: A tutorial on DevContainers
Tags: vscode, docker, tutorial, productivity
Published: 2023-10-18 13:35:07

You started on a new project, went through all the onboarding process, met your team, discovered the details of your new challenge and is now able to start the coding. Sounds exciting right?

But you know that before you start to define any variable in your IDE you need to setup all the tools, languages and environments for the project to work. And now the first challenge of you project is not to develop a new useful feature for the client, but to debug the tools that are not working as expected in your machine because of some computer magic.

This all sounds familiar. Right?

## What are a Development Containers (or DevContainers)?

Development Containers are a specification that allows you to use containers as a full-featured development environment (read more on [containers.dev](https://containers.dev/)). This enables users and teams to create a consistent, shareable, reproducible and isolated development environment.

<p align="center">
    <img src="https://code.visualstudio.com/assets/docs/devcontainers/containers/architecture-containers.png" alt="DevContainers Architecture"/>
</p>

Some benefits of using DevContainers are:

* A faster onboarding for anyone that wants to join your project. Now a single command is sufficient to get people ready to contribute;
* A shareable and reproducible environment that reduces problems related to different versions of packages and tools between team members;
* An isolated environment from your OS that prevents your local OS from interfering with your project.

## What you are going to learn here?

This tutorial will help you set a DevContainer for VSCode using the [official extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) by Microsoft. At the end you will be able to create your own custom container, use it on VSCode and personalize it without interfering in your team's environment.

## Requirements

DevContainers work on Windows and Linux (including WSL2). For this tutorial to work you will need:
  
* **VSCode**: You can install following this [link](https://code.visualstudio.com/);
* **Docker Engine**: You can install following the steps on [Docker Engine Install](https://docs.docker.com/engine/install/).

## Setup your DevContainer

### Step 01: Setup your .devcontainer folder

For VSCode to autodetect the container you should have at least a `Dockerfile` with your container's specification and a `devcontainer.json` file containing metadata and settings for the environment. All these files should be in a `.devcontainer` folder on the root of your project.

```markdown
├── .devcontainer
│   ├── ...                 <- Any other related file that you want
│   ├── Dockerfile          <- Container Dockerfile definition
│   └── devcontainer.json   <- DevContainer's metadata and settings
└── ...
```

### Step 02: Creating the Dockerfile

Here you can create a common Dockerfile with any base image, ARGs, ENVs and commands that you want your container to do. The example below will create a Ubuntu container with a python environment using Pyenv and Poetry. We also use the `root` user but if you want to use a non-root user you can follow [this link](https://code.visualstudio.com/remote/advancedcontainers/add-nonroot-user).

```dockerfile
FROM ubuntu:22.04
ARG PYENV_VERSION=2.3.18
ARG POETRY_VERSION=1.5.1
ARG PYTHON_VERSION=3.11.3

ENV DEBIAN_FRONTEND=noninteractive

# Copy custom scripts and set the required permissions.
COPY custom-scripts/ /tmp/scripts/
RUN chmod +x /tmp/scripts/*

# Basic packages and requirements
RUN apt update && apt install -y git curl ca-certificates gnupg lsb-release locales locales-all nano jq wget

# Setting Locales
ENV LC_ALL en_US.UTF-8
ENV LANG en_US.UTF-8
ENV LANGUAGE en_US.UTF-8

# Install Pyenv and Python
ENV PYENV_ROOT="/usr/local/pyenv" PYENV_GIT_TAG=v${PYENV_VERSION}
ENV PATH="${PATH}:${PYENV_ROOT}/bin"
RUN apt-get install -y build-essential libssl-dev zlib1g-dev libbz2-dev libreadline-dev libsqlite3-dev curl libncursesw5-dev xz-utils tk-dev libxml2-dev libxmlsec1-dev libffi-dev liblzma-dev \
    && curl https://pyenv.run | bash \
    && pyenv install ${PYTHON_VERSION} \
    && pyenv global ${PYTHON_VERSION}

# Install Poetry
RUN unset PYENV_VERSION && pyenv exec pip install poetry==${POETRY_VERSION}
```

#### Lifecycle Scripts

You might have noticed the snippet in the Dockerfile above

```dockerfile
# Copy custom scripts and set the required permissions
COPY custom-scripts/ /tmp/scripts/
RUN chmod +x /tmp/scripts/*
```

What it does is to copy some files from the `custom-scripts` folder located inside the `.devcontainer` and give the necessary permissions for them to be executed. This will be useful later on  where we are going to run these scripts in some phases of the Docker lifecycle (e.g. when it creates, every time it starts and so on).

## Step 03: The devcontainer.json file

The `devcontainer.json` file is where you specify configuration for VSCode to run your containers. You can see the complete specification of settings and metadata on [Development Containers metadata reference](https://containers.dev/implementors/json_reference/).

```json
{
    "name": "DevContainer",
    "build": {
      "dockerfile": "Dockerfile",
      "args": {
          "PYENV_VERSION": "2.3.18",
          "POETRY_VERSION": "1.5.1",
          "PYTHON_VERSION": "3.11.3"
      }
    },
    "mounts": [
      "source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind,consistency=default"
    ],
    "postStartCommand": "/bin/bash /tmp/scripts/poststart.sh",
    "customizations": {
      "vscode": {
        "extensions": [
          "ms-python.vscode-pylance",
          "njpwerner.autodocstring",
          "bungcip.better-toml",
          "ms-python.black-formatter"
        ],
        "settings": {
          "editor.rulers": [79, 110],
        }
      }
    }
}
```

### Build

The `build` information is responsible to setup the information for VSCode to build your container. This includes args, target, caches, the path to the Dockerfile and so on. The snippet below defines the path of the Dockerfile and set the args. See more on [Image or Dockerfile specific properties](https://containers.dev/implementors/json_reference/#image-specific).

```json
{
  ...
  "build": {
    "dockerfile": "Dockerfile",
    "args": {
      "PYENV_VERSION": "2.3.18",
      "POETRY_VERSION": "1.5.1",
      "PYTHON_VERSION": "3.11.3"
    }
  },
  ...
}
```

### postStartCommand and Lifecycle Scripts

Lifecycle scripts is a feature by DevContainers where you can make some commands run in some phases of the Docker Lifecycle. For example, on our example we use the `postStartCommand` that runs everytime we start the container. This is useful for example to always update your OS or to make all your python dependencies installed. But there are more, you can use for example `onCreateCommand` that will execute only when the container is created. You can read more about this on [Lifecycle scripts](https://containers.dev/implementors/json_reference/#lifecycle-scripts) and [Start a process when the container starts](https://code.visualstudio.com/remote/advancedcontainers/start-processes).

```json
{
  ...
  "postStartCommand": "/bin/bash /tmp/scripts/poststart.sh",
  ...
}
```

### Customizations

The customizations property is where DevContainers define information specific for your IDE. For VSCode we have two properties `vscode.settings` and `vscode.extensions` that defines the default settings and installed extensions for the environment. The example `devcontainer.json` adds some extensions such as pylance, autodocstring, better-toml and black-formatter to the container and sets two editor rules for 79 (PEP8) and 110 characters.

```json
{
  ...
   "customizations": {
      "vscode": {
        "extensions": [
          "ms-python.vscode-pylance",
          "njpwerner.autodocstring",
          "bungcip.better-toml",
          "ms-python.black-formatter"
        ],
        "settings": {
          "editor.rulers": [79, 110],
        }
      }
    }
}
```

Some tips for making this configuration easier:

1. For **extensions**, you can go to any extension in the tab, expand the options on the `...` (three dots) and click on the option `add to devcontainer.json` that the file will be automatically managed.
2. For **settings** you can open the command pallet on `Ctrl + Shift + P` and go to any configuration that you wish to alter, click on the `More actions` represented by a gear icon, `copy setting as json` and add this to the json file.

### Mounts (and Docker-From-Docker)

Mounts is the metadata used to specify your container's volumes as in any docker container. A common example of mount that is used is when you want to run docker within your container. Here we are setting up the Docker-from-Docker (or Docker-outside-of-Docker) approach where we mount the host's docker socket into the container so any build or docker command will actually execute on the host. See more on [General devcontainer.json properties](https://containers.dev/implementors/json_reference/#general-properties)

```json
{
  ...
  "mounts": [
    "source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind,consistency=default"
  ],
  ...
}
```

## Personalizing your environment with dotfiles

Each Linux user has their own set of settings for their software, some use a personalized shell like zshell, other use Vim with some tweaks as their main editor. To maintain the productivity of everyone it is important to maintain these settings across environments. Many Linux software uses dotfiles as their main way to store their settings and this is what we are going to explore.

One of the features of the DevContainer extension on VSCode is that we can pull dotfiles directly from a Github repository (public or private). This can be done by adding the following settings on your VSCode Settings (JSON) (or looking for similar settings in the UI).

```json
{
  "dotfiles.repository": "your-github-id/your-dotfiles-repo", 
  "dotfiles.targetPath": "~/dotfiles",
  "dotfiles.installCommand": "~/dotfiles/install.sh"
}
```

What this means is that VSCode will copy all the content of the `dotfiles.repository` into the folder `dotfiles.targetPath` inside the container and will execute the `dotfiles.installCommand`. You can omit the `installCommand` and it will default to a `install.sh` script inside the `targetpath`. You can read more on
[Personalizing with dotfile repositories](https://code.visualstudio.com/docs/devcontainers/containers#_personalizing-with-dotfile-repositories).

A real example of a `install.sh` (used by me) is presented below. This script will install zshell, [Powerlevel10K](https://github.com/romkatv/powerlevel10k) theme and create symbolic links of my personalized settings into the required ones in the system.

```shell
#!/usr/bin/env sh
set -e

DOTFILES_LOCATION="${HOME}/dotfiles"    
export DOTFILES_LOCATION;

apt install -y zsh fonts-powerline fzf

if [ -d "${HOME}/.oh-my-zsh" ]; then
  printf "oh-my-zsh is already installed\n"
else
  sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended # Zshell
  git clone --depth=1 https://github.com/romkatv/powerlevel10k.git ${ZSH_CUSTOM:-$HOME/.oh-my-zsh/custom}/themes/powerlevel10k
fi

echo "creating symlinks for zsh"
ln -sf "${DOTFILES_LOCATION}/zsh/.zshrc" "${HOME}/.zshrc"
ln -sf "${DOTFILES_LOCATION}/zsh/.p10k.zsh" "${HOME}/.p10k.zsh"
ln -sf "${DOTFILES_LOCATION}/zsh/.zprofile.zsh" "${HOME}/.zprofile.zsh"
```

Note that everything in this section is completely optional, and you can use the default configuration for each software.

## Conclusions

Learn about DevContainers were a turning point in the projects that I worked with reducing problems related to manual configuration of a series of tools on different environments. This tutorial should help you achieve the same benefits as I had by setting your custom development environment.

Thanks for Reading! Please, feel free to drop any suggestions, discussions, tips on the section below!

## Sources

* [Developing inside a Container](https://code.visualstudio.com/docs/devcontainers/containers)
* [Advanced container configuration](https://code.visualstudio.com/remote/advancedcontainers/overview)
* [Dev Container metadata reference](https://containers.dev/implementors/json_reference/)