DEV Community

Cover image for Creating an isolated and reproducible development environment: A tutorial on DevContainers
Gabriel Lima
Gabriel Lima

Posted on

Creating an isolated and reproducible development environment: A tutorial on DevContainers

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). This enables users and teams to create a consistent, shareable, reproducible and isolated development environment.

DevContainers Architecture

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 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:

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.

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

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.

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}
Enter fullscreen mode Exit fullscreen mode

Lifecycle Scripts

You might have noticed the snippet in the Dockerfile above

# Copy custom scripts and set the required permissions
COPY custom-scripts/ /tmp/scripts/
RUN chmod +x /tmp/scripts/*
Enter fullscreen mode Exit fullscreen mode

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.

{
    "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],
        }
      }
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

{
  ...
  "build": {
    "dockerfile": "Dockerfile",
    "args": {
      "PYENV_VERSION": "2.3.18",
      "POETRY_VERSION": "1.5.1",
      "PYTHON_VERSION": "3.11.3"
    }
  },
  ...
}
Enter fullscreen mode Exit fullscreen mode

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 and Start a process when the container starts.

{
  ...
  "postStartCommand": "/bin/bash /tmp/scripts/poststart.sh",
  ...
}
Enter fullscreen mode Exit fullscreen mode

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.

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

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

{
  ...
  "mounts": [
    "source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind,consistency=default"
  ],
  ...
}
Enter fullscreen mode Exit fullscreen mode

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).

{
  "dotfiles.repository": "your-github-id/your-dotfiles-repo", 
  "dotfiles.targetPath": "~/dotfiles",
  "dotfiles.installCommand": "~/dotfiles/install.sh"
}
Enter fullscreen mode Exit fullscreen mode

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.

A real example of a install.sh (used by me) is presented below. This script will install zshell, Powerlevel10K theme and create symbolic links of my personalized settings into the required ones in the system.

#!/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"
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)