Here’s a hypothetical: you have to on-board another developer and, on top weathering their entirely reasonable questions about why this or that tool isn’t being used, you have to help them get their development environment setup. In addition to explaining that, yes, this other tool would be great and improve the project but time is kind of short right now and there are features that need to be developed you suddenly notice that they are not using Linux. All of your suggestions work, but not quite, and it is truly frustrating.

Development Containers

At this point, like all of us, you wonder “is there a better way?” There’s always a better way and the idea of using Docker to support your development environment is nothing new. The interesting bit is that Visual Studio Code is bringing some tooling to the table that makes this much easier.

Before we go further, let me clarify: a development container isn’t just one container. You can also provide a Docker compose file where you can describe all of the other containers your need to work on your project (a database server, web server, etc.)

Put this all together and every new developer on your project is literally one click away from being productive.

  • Fastest: They can click the link on GitHub and use the web-based version of Code and get to work within minutes.
  • Fast: They can click one more button to spin up the required containers on GitHub and then connect from their own, locally installed and customized copy of Code.
  • Somewhat Quickly: They can clone the project and open it in their local copy of Code, letting Code provision the containers on their local machine
  • Not quick at all: For the truly picky developers, they can do whatever they like on their own time, we’ve already provided three working options!

In my opinion, they real pain-point we’re avoiding here is the morass of confusion and mediocre performance that is Docker Desktop. I could detail my complaints about the product but the fact is that if you’re reading this article, we’re all on the same page already.

Getting It Setup

If you’re on the fence about Visual Studio Code, then just put your concerns aside and trust me; it’s worth it. It’s free, it’s popular, it’s easy to install and there are extensions available for nearly every feature it lacks. It may not be every developer’s preference but it’s easily good enough to get the job done.

Next install (if you haven’t already)…

After that, the first step is to get the development container setup and working. You can start from scratch by creating a directory for your sample project (maybe called “new-project”, avoid spaces in the directory name) and then a directory inside of that called .devcontainer with a file called devcontainer.json inside of that. The simplest file will contain only a pointer to a Docker image. Using Code, create and save the following devcontainer.json file.

{
  "image": "mcr.microsoft.com/vscode/devcontainers/php:8-bullseye"
}

That will spin up a development container based on Microsoft’s PHP image, which in turn is built on Debian Bullseye. To try it out, choose “Command Palette…” from under the “View” menu and start typing “Developer: Reload Window” to choose that command.

Reload the sample project in Code

Code will close and re-open the sample project and then it will prompt you to install the recommended extensions. Click on the “Install” button.”

Install recommended extensions

Now that the extension is installed, Code will suggest that you re-open the project one more time but, this time, open it in a container.

Reopen the project in a Docker container

This time Code will open up the project and then spin up a new Docker container using the image we specified in the .devcontainer.json file. It will then install it’s own set of tools inside that container to make it easier to edit files, run code, etc.

Code builds and starts development container

When it’s all done you’ll be back at the a regular Code window, with your project files on the left-hand side. But if you select “New Terminal…” from under the “Terminal” window, you’ll see that the terminal has opened a session inside of the container!

Code's Terminal opens session in the container

Customize the Development Container

This is the simplest scenario where we just plop a development container definition into our project and Code spins it up. But what if we needed to add some of our own tools? Perhaps our team relies heavily on Flyway for database migrations and HTTPie for testing our RESTful endpoints. This can easily be done by writing our own Docker image definition. First remove the “image” attribute from your devcontainer.json file and replace it with a “dockerFile” attribute that points to our soon-to-be-created Dockerfile.

{
    "dockerFile": "Dockerfile"
}

Next, create a new Dockerfile next to your devcontainer.json file and add the following recipe to it.

FROM mcr.microsoft.com/vscode/devcontainers/php:8-bullseye

# update apt
RUN apt update

# install httpie
RUN apt install -y httpie

# install flyway
WORKDIR /usr/local
RUN wget -qO- https://repo1.maven.org/maven2/org/flywaydb/flyway-commandline/8.0.0-beta1/flyway-commandline-8.0.0-beta1-linux-x64.tar.gz \
| tar xvz && sudo ln -s `pwd`/flyway-8.0.0-beta1/flyway /usr/local/bin

Instead of using the PHP image directly we use it as a starting point for our own container image. We run apt update to pull down the latest list of packages for the distribution and then we use apt to install HTTPie. With that done, we follow the instructions from the Flyway website to get that tool installed as well.

Save the file and then reload the window (by choosing the option from the command palette). Code will close and then open the project and prompt you to open the project yet again in the container, go ahead and do so. While it’s doing that it will notice that the development container definition has changed and ask you if you’d like to rebuild it, do that as well.

Code rebuilds the development container

Code will delete the Docker image, rebuild the image using our Docker file and then drop us at the project window. You can open up a new terminal panel to verify that our will truly has been done and that our tools are now available.

Verify our tools are installed

At this point you should start to get excited, thoughts of time saved and frustrations avoided racing through your mind. But wait, there’s more!

Add More Containers to the Development Environment

If we have anything at all in common, your next though is something along the lines of “how do I get more containers in there, for things like a database or a web server?” In the same way we added a Dockerfile we can also add a Docker Compose definition. When Code starts up the project it will bring up all of the containers in the compose definition and then connect to the development container. Let’s add a database server to our devcontainer.

Open up your devcontainer.js file, remove the dockerFile attribute and replace it with a dockerComposeFile entry.

{
  "dockerComposeFile": "docker-compose.yml",
  "service": "development",
  "workspaceFolder": "/workspace"
}

We point our devcontainer definition at our Docker Compose file, with the service attribute we let Code know which container we’d like it to connect to when that stack for the project is brought up. Lastly, we add a workspaceFolder attribute and provide the complete path where we want our project files mounted.

We also need to create that compose file. Create a new file called docker-compose.yml right next to our devcontainer definition and add the following.

services:
  development:
    build: 
      context: .
      dockerfile: Dockerfile
    volumes:
      - ..:/workspace
    stdin_open: true

In the stanza above, we describe the container that we’ll be using for our development environment. We point it at our existing Dockerfile and we map in the volume that will hold our project files (the directory above the one holding our devcontainer definition, mounted at “/workspace”). The context attribute tells compose to look for files in the current directory, stdin_open indicates that we’ll be connecting with an interactive session.

That takes care of our development container, now we’ll add a database server. Since we’re using PHP for this project it’s clear that the database needs to be MySQL. Before we add the database service to our compose file, let’s setup the credentials we’d like to use. Docker Compose will read a file named .env when it starts assembling the stack and it will make the variables in that file available to our script. Create a new file named .env next to your docker-compose.yml file and add the text below.

DATABASE_HOST=mysql_db
DATABASE_USER=example_account
DATABASE_PASSWORD=applesauce
DATABASE_DB=example

The database credentials will now be in environment variables that we can use in our compose file. Add the following service you your docker-compose.yml file to get our database server setup.

  mysql_db:
    hostname: mysql_db
    image: mysql/mysql-server:latest
    environment:
      - MYSQL_ROOT_PASSWORD=${DATABASE_PASSWORD}
      - MYSQL_DATABASE=${DATABASE_DB}
      - MYSQL_USER=${DATABASE_USER}
      - MYSQL_PASSWORD=${DATABASE_PASSWORD}

Before we spin this up, it would be nice to have an convenient way to interact with our database server from inside Code…

Add Visual Studio Code Extensions

In fact, it would also handy to have Code extensions that we’ve agreed to use in our project installed along with everything else. Let’s add the PHP extension pack and then a MySQL client. Open up the your devcontainer.json file and add the extensons attribute listed below.

{
    "dockerComposeFile": "docker-compose.yml",
    "service": "development",
    "workspaceFolder": "/workspace",
    "extensions": [
        "felixfbecker.php-pack",
        "cweijan.vscode-mysql-client2"
    ]
}

Open the command palette and ask Code to reload the folder. Code will prompt you to rebuild the devcontainer as the configuration files have changed; agree and let it bring up the new environment. When it’s finished you will be back at your Code window, the new extensions will be listed in the “Extensions” panel under “Dev Container - Installed”.

Dev Container Extensions

You can click on the icon for our database client and type in the connection information (remember the “hostname” attribute in our compose file?) to connect to our new MySQL database.

Connect to Database Server

Now we’re starting to get somewhere! We have a container we can use for development, with all of our tools and Code extensions installed and another container running our database server. From here we could add a third container to run our web server and serve our application. We could even add migrations to get our application in a usable state, ready for developers to start working on new features.

This article is already getting pretty lengthy, we won’t detail getting migrations or a web server working here. You can take a stab at it on your own or you can checkout the full example project on GitHub.

GitHub Codespace

The last thing I wanted to mention is that we have everything we need to host our development environment on GitHub Codespace. If we publish this on GitHub, our devcontainer definition will get picked up and (if it’s enabled for your account) the “Codespace” tab with a “New codespace” button will appear for anyone who stumbles upon the project. Anyone who would like to contribute can simply click the button and start work in seconds. Codespace will use the their GitHub credentials to handle pushing, pulling and committing code.

Summary

I encourage you to checkout the example project on GitHub, you should try it out in Codespace as well as launching the stack in Codespace and then connecting with your own, local version of Visual Studio Code. In my opinion this latter option is pretty compelling: you can customize Code the way you like it (including extensions that you prefer) and then connect to a set of running containers hosted at GitHub.

This is especially compelling if you are on a platform that only provides Docker through Docker Desktop (MacOS or Windows). While Docker Desktop is fine for small projects, anything of significant size will bring Docker Desktop to it’s knees. The real pain point here is sharing files from the host machine to the virtual machine that’s running Linux (and Docker) and then sharing those files once more with the actual containers running in Docker.

I hope this article has given you an idea of how Code development containers work and how they might fit into your own workflow. In my opinion, it’s an exciting feature and really gets us to a place where using Docker to containerize our development environment starts to become accessible to everyone, regardless of their level of experience with Docker and the related tooling. While Codespace might feel a bit gimmicky, it does allow anyone who may want to contribute to your project a quick and easy way to get started and to submit that pull request.