My profile picture. My mom says I'm a handsome boy!
← All Posts

Creating Devcontainers for VS Code and GitHub Codespaces

#Code, #Guides, #Tools

I’ve been adding development containers to several of my projects with the news that GitHub Codespaces is coming out of beta soon. Codespaces is a browser-based code environment - it’s wicked cool! It works by creating a Docker-based container in the cloud, and then loading VS Code in the browser to write code with. I didn’t have a lot of experience with Docker before playing around with Codespaces this week, and was intimidated going into it - but it turned out customizing the development containers wasn’t that complicated!

Microsoft has created a lot of stellar presets that you can use to get started with. I’ve used these for several projects, but began creating my own so I could exercise greater control over the environment and use my preferred tools (for instance, using Volta instead of NVM).


Why create a devcontainer?

The VS Code documentation on devcontainers describes the feature as “a local-quality development experience — including full IntelliSense (completions), code navigation, and debugging — regardless of where your tools (or code) are located.”

There are several benefits to using devcontainers instead of developing locally, but chief among them is you have complete control over the environment your project runs in. For Aquarius developers need to have a PostgreSQL database running, have Node.js >14.9 installed, and also run several scripts to configure the project’s codebase before they can begin coding. There is a lot of room for things to go haywire here. On macOS, getting PostgreSQL to run is a breeze; on WSL2, it is not. Rather than leaving the environment as a potential barrier to entry, I can create a devcontainer and setup PostgreSQL and Node.js for the user in the way Aquarius needs.


Setting up your Computer and VS Code

To make a devcontainer we need to create a docker image and a devcontainer.json file that tells VS Code how to load it. To begin, you'll need to install the following:

Creating the devcontainer directory

Now let’s start creating our container! To start, make a .devcontainer directory in the root of your codebase. We’ll be putting all the files below into it.

Creating the Dockerfile

The Dockerfile defines the virtual machine your code runs on. If you're new to Docker (like I am!), there are a bunch of fantastic images you can use as a base to build on top of - it makes this step pretty easy as long as you don't need a bunch of custom components.

To make one, create a .devcontainer/Dockerfile file that pulls in the image you need. For my Ruby on Rails applications, I'm using this:

.devcontainer/Dockerfiledocker
1# Create a variable with a default value of 2.7
2ARG VARIANT=2.7
3
4# Use the official Ruby container
5FROM ruby:${VARIANT}
6
7# Variables that will setup a non-root user.
8ARG USERNAME=vscode
9ARG USER_UID=1000
10ARG USER_GID=$USER_UID
11
12# Copy my devcontainer setup script to the container
13COPY scripts/setup.sh /tmp/scripts/
14
15# Run commands to setup the container
16RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
17 # Remove imagemagick due to https://security-tracker.debian.org/tracker/CVE-2019-10131
18 && apt-get purge -y imagemagick imagemagick-6-common \
19 # Install common packages, non-root user, rvm, core build tools
20 && bash /tmp/scripts/setup.sh "${USERNAME}" "${USER_UID}" "${USER_GID}" \
21 && apt-get autoremove -y && apt-get clean -y && rm -rf /var/lib/apt/lists/* /tmp/scripts

This is a modified version of the presets Microsoft has published - I've removed parts that weren't necessary for my setup.

If you’re using Node.js, it might start like this instead:

docker
ARG VARIANT=14.9
FROM node:${VARIANT}

Adding Databases with Docker Compose

Docker Compose is a great way of adding services to your environment. If your application needs a database for instance, an easy way to add it to your devcontainer is creating a .devcontainer/docker-compose.yml file that loads it in an image. For PostgreSQL, that looks like this:

.devcontainer/docker-compose.ymlyml
1version: "3"
2
3services:
4 app:
5 build:
6 context: .
7 dockerfile: Dockerfile
8 args:
9 # These set the ARG values in your Dockerfile
10 VARIANT: 2.7
11 USER_UID: 1000
12 USER_GID: 1000
13
14 volumes:
15 - ..:/workspace:cached
16
17 # Overrides default command so things don't shut down after the process ends.
18 command: sleep infinity
19
20 # Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function.
21 network_mode: service:db
22
23 db:
24 image: postgres:latest
25 restart: unless-stopped
26 volumes:
27 - postgres_data:/var/lib/postgresql/data
28 environment:
29 POSTGRES_PASSWORD: postgres
30 POSTGRES_USER: postgres
31 POSTGRES_DB: postgres
32
33# Persist database information after your VM shuts down (just these two lines!)
34volumes:
35 postgres_data:

Piecing it together with a Devcontainer File

Now for the final part! We can piece it all together with a .devcontainer/devcontainer.json file that creates our Docker image and configures VS Code for us.

First, let’s add the basics on how to start the devcontainer:

.devcontainer/devcontainer.jsonjson
1{
2 "name": "project-name",
3 "build": {
4 "dockerfile": "Dockerfile",
5 "args": {}
6 },
7 "forwardPorts": [3000, 5432]
8}

If you're using Docker Compose, this will be slightly different:

.devcontainer/devcontainer.jsonjson
1{
2 "name": "project-name",
3 "dockerComposeFile": "docker-compose.yml",
4 "service": "app",
5 "workspaceFolder": "/project-name",
6 "forwardPorts": [3000]
7}

Change the forwardPorts as needed - I used 3000 since it is the default ports for Rails, Express, and Next.js. If you’re running a Rails or Next.js app in your devcontainer, loading http://localhost:3000 through your local browser will load the code running in the devcontainer!

Next, add some default VS Code settings and extensions to install automatically by adding this to the file:

.devcontainer/devcontainer.jsonjson
1{
2 //...
3 "settings": {
4 "terminal.integrated.shell.linux": "/bin/zsh",
5 "sqltools.connections": [
6 {
7 "name": "Container database",
8 "driver": "PostgreSQL",
9 "previewLimit": 50,
10 "server": "localhost",
11 "port": 5432,
12 "database": "postgres",
13 "username": "postgres",
14 "password": "postgres"
15 }
16 ],
17 "files.eol": "\n",
18 "files.insertFinalNewline": true,
19 "files.trimFinalNewlines": true,
20 "files.trimTrailingWhitespace": true,
21 "editor.formatOnSave": true,
22 "editor.codeActionsOnSave": {
23 "source.organizeImports": true
24 }
25 },
26 "extensions": [
27 "dbaeumer.vscode-eslint",
28 "mtxr.sqltools",
29 "mtxr.sqltools-driver-pg",
30 "wix.vscode-import-cost",
31 "esbenp.prettier-vscode",
32 "prisma.prisma"
33 ]
34}

When VS Code loads this devcontainer it will automatically install the extensions and use the configured settings - it’s a great way to help new contributors get set up by including defaults like ESLint and Prettier.

Some apps might also have initialization scripts that need to be run after the repository is cloned - for instance, npm install or bin/setup are common ones. You can automate that step by adding:

json
"postCreateCommand": "npm install",

Check out the official documentation to learn more about what's possible!

My complete devcontainer.json for Aquarius:

.devcontainer/devcontainer.jsonjson
1{
2 "name": "Aquarius",
3 "dockerComposeFile": "docker-compose.yml",
4 "service": "app",
5 "workspaceFolder": "/workspace",
6 "settings": {
7 "terminal.integrated.shell.linux": "/bin/zsh",
8 "sqltools.connections": [
9 {
10 "name": "Container database",
11 "driver": "PostgreSQL",
12 "previewLimit": 50,
13 "server": "localhost",
14 "port": 5432,
15 "database": "postgres",
16 "username": "postgres",
17 "password": "postgres"
18 }
19 ],
20 "files.eol": "\n",
21 "files.insertFinalNewline": true,
22 "files.trimFinalNewlines": true,
23 "files.trimTrailingWhitespace": true,
24 "editor.formatOnSave": true,
25 "editor.codeActionsOnSave": {
26 "source.organizeImports": true
27 }
28 },
29 "extensions": [
30 "dbaeumer.vscode-eslint",
31 "mtxr.sqltools",
32 "mtxr.sqltools-driver-pg",
33 "wix.vscode-import-cost",
34 "esbenp.prettier-vscode",
35 "prisma.prisma"
36 ],
37 "forwardPorts": [3000, 3030, 5432],
38 "postCreateCommand": ["npm install", "npm run db:create"]
39}

Setup Scripts

This step is optional, but I like to run a script that installs shared utilities and finishes configuring the environment as part of my Docker image. For my setup scripts, I start by copying common-debian.sh from Microsoft's official containers and then modifying it to include tools I want and removing those I don’t.

Install your dotfiles!

Finally, let's tell VS Code to automatically clone and install our dotfiles when opening a devcontainer. This step is pretty easy - there are extension settings that let you configure your dotfile repository location and installation script. Mine looks like this:

Dotfiles Settings

Conclusion

And there you have it! Now anyone who has VS Code can connect to your devcontainer and automatically get an environment that's set up for them to work with. If you use GitHub Codespaces, you can even code in a browser now!

GitHub Codespaces in Safari

Devcontainers are really exciting, and big tech uses this style of development for strong reasons. Microsoft has made it easy for smaller projects to get the same benefits, and I can't wait to add devcontainer support to my projects.