We have all been there, onboarding to a new codebase with an outdated doc, running into hurdle after hurdle and finally ending up with a jank setup that barely works - until you run a system update...😭. devcontainers attempts to address this problem by combining reproducible and isolated Docker containers with the powerful Visual Studio Code editor (VSCode) to achieve a cohesive experience where we develop inside a container.
Why adopt devcontainers?
- A team can share and maintain a dev environment as code
- Low setup cost of dev environments on new machines, which makes for easy onboarding of new contributors
- No conflicts with existing dev environments on the same machine, i.e. no more managing multiple Node versions
How does this work?
VSCode has the capability to develop in a number of remote environments1. This involves VSCode client on the local host connecting to VSCode server running on the remote host. The remote host is configured with everything needed to support development, e.g. os, tools, configuration. Docker2 is one of the supported remote environments.
Setting up a devcontainer
A devcontainer environment is configured within the
.devcontainer directory, which includes a
docker-compose.yml and a
devcontainer.json. At a high level, the
Dockerfile defines the main container where runtimes and tools can be installed; the
docker-compose.yml defines how to run the
devcontainer.json links the
docker-compose.yml to VSCode and manages the extensions and settings to be installed.
There are a series of base images provided by the VSCode team that we can extend[^devcontainer-base-images] - these typically install some base tooling for the language in a Linux distro like Debian.
For example, the
nvm and some Unix tools like
git etc. Additional tooling can be installed by extending the base image in a
version: "3.8" services: main: build: context: . dockerfile: Dockerfile volumes: - ..:/workspace:cached command: sleep infinity
In accordance to the reference
docker-compose.yml4, we define a
main service that represents the container we will develop inside. It is configured to build the previously defined
Dockerfile, mount the project directory (
../) as a volume in the container at
/workspace and run indefinitely with
We provide the entrypoint to the devcontainer as the
main service in the
docker-compose.yml. We can defined VSCode
extensions 5 to be installed in the container. In this example, we are setting the default terminal to
zsh and install an
eslint integration6 and GitHub Copilot7.
The extensions adds VSCode commands for managing a devcontainer. First, open the command palette in VSCode with CTRLSHIFTP.
Launch a devcontainer
In order to launch a devcontainer, look for the
Remote-Containers: Open Folder in Container... to open a folder or
Remote-Containers: Open Workspace in Container... to open a VSCode workspace10.
Rebuild a devcontainer
If you have made changes to the devcontainer and want them to be reflected, look for the
Remote-Containers: Rebuild Container. This is also for getting out of sticky situations where the client is hanging - similar to the
Developer: Reload Window command.
Remove a devcontainer
Containers are not removed from Docker between VSCode sessions. This allows us to save time by starting where we left off from a previous session. In some cases we need to purge the devcontainer. To do this, look for the
View: Show Remote Explorer - this presents a list of all devcontainers. Right click on a container to reveal the option to remove it.
Advanced use cases
docker-compose enables us to run and connect multiple containers with each other. A great use case is for running local databases to test against. These database containers can be removed and rebuilt without impacting the development environment.
version: '3.8' services: main: build: context: . dockerfile: Dockerfile volumes: - ..:/workspace:cached networks: - default depends_on: - dynamodb - pg environment: DYNAMODB_ENDPOINT: http://dynamodb:8000 PG_CONNECTION_STRING: postgres://postgres:[email protected]:5432/postgres?sslmode=disable command: sleep infinity dynamodb: image: amazon/dynamodb-local:latest networks: - default pg: image: postgres networks: - default environment: POSTGRES_PASSWORD: postgres pgweb: image: sosedoff/pgweb ports: - 5433:8081 networks: - default depends_on: - pg environment: DATABASE_URL: postgres://postgres:[email protected]:5432/postgres?sslmode=disable networks: default:
The above example runs a
postgres instance on the same
default network11 as the
main service. This enables the main service to connect to them with the conveniently injected environment variables
PG_CONNECTION_STRING. There is also a
pgweb instance that is exposed to the host machine at port
5433 for conveniently inspecting the
postgres database in a browser.
Often times we may need be developing a HTTP API and would like to ping it from the host machine. This is possible with forwarded ports[^devconatiner-forward-ports].
There are a few ways to do this but the most portable is to publish the port in the
docker-compose.yml as it is not strictly tied to the devcontainer runtime. This simply involves adding the
port field to the
version: "3.8" services: main: build: context: . dockerfile: Dockerfile port: - 8080:8080 volumes: - ..:/workspace:cached command: sleep infinity
Docker in Docker
Often times we need Docker to build and run containers as part of development. This can be complex are already developing inside a container with devcontainers.
The simplest way to get this is to use a devcontainer feature12, which are essentially scripts that run on the devcontainer. One of them being
docker-in-docker13. This feature is enabled by including it in the feature list in the
The case against devcontainers
The elephant in the room is the dependency on VSCode. There is an open spec14 but no adoption by other vendors (i.e. JetBrains15). It is a difficult task to ask a dev to move editors - I've definitely been guilty of this after reluctantly working in projects that require IntelliJ.
Balance this with the value of reducing friction for a new contributor or passerby to get setup and make their first PR; runnable documentation of an ideal dev environment; and when our local environment has gone to shit we can always fallback. If you really don't like VSCode, you can always SSH into the container and use
vi 😅 - in fact there is a devcontainer CLI16 for running outside a VSCode environemnt such as for CI pipelines.
There is a lot of magic, e.g. git config integration[^git-integraton], port forwarding17, etc. In most cases these work perfectly, but when things go wrong it can be difficult to find some help from Google as it is still a niche feature with a small community of users.
There are some known limitations with Docker on various operating systems especially relating to disk performance18.
Some19 have encountered extremely slow package management in NodeJS project with a large
node_modules folder🙄. The issues seem to stem from VSCode bind mounting2021 the local workspace into the container, which can degrade performance22 as it attempts to maintain consistency between the host and container. Some solutions involve excluding the
node_modules 23 or modifying the consistency level to prioritise the container24.
Devcontainers are a big step in improving developer experience in teams, especially for onboarding new contributors. It leverages Docker, which is a defacto industry standard, but the devcontainer runtime remains tightly integrated with VSCode, which could be a risk for some. Personally, I will be using it for all my personal projects as I love the portability it offers - enabling me to get instantly setup my projects on any computer with Docker and VSCode installed.