How to configure a Docker + VS Code development environment
Tags: vscode, docker, webdev
Published: 2021-04-07 04:04:41

Há uma versão em português brasileiro aqui:

{% link davidrios/como-configurar-um-ambiente-de-desenvolvimento-com-docker-vs-code-2pc8 %}

After working with Docker for a while on some projects, I arrived at a configuration that works well for me and my colleagues, so I decided to share it in case it's useful to more people.

Keep in mind it's based on my personal experience, it might not use the best practices or apply to your case. Suggestions on improvements are welcome though!

I'll show you how to setup a full project from scratch and run it completely isolated within containers, so the environment will (hopefully) be reproducible and run the same, no matter your host OS.

I use Ubuntu as my daily driver, so this guide will be from that perspective, but you should be able to adapt it to your OS of choice without much trouble. It'll probably work well in Windows using WSL 2.

## Table of Contents

- [The project](#the-project)
- [Requirements](#requirements)
- [Starting out](#starting-out)
- [The backend app](#the-backend-app)
- [The frontend app](#the-frontend-app)
- [A simple nginx proxy](#nginx-proxy)
- [VS Code for the win](#vscode)
    - [Configuring for the backend](#vscode-backend)
    - [Configuring for the frontend](#vscode-frontend)
- [Addendum](#addendum)
- [Conclusion](#conclusion)


## The project <a name="the-project"></a>

To illustrate all steps, and be as realistic as possible, we'll create a basic web application using the following stack:

- Backend: [Python (Django)](https://www.djangoproject.com/)
- Database: [PostgreSQL](https://www.postgresql.org/)
- Frontend: [VueJS (Quasar)](https://quasar.dev/)

At the end we'll have a consistent environment for code editing with:

- Code-completion
- Linting
- Debugging
- Well defined editor settings for everyone working on the project

The stack choice is based on what I have more familiarity, but all the concepts in this guide should apply to any stack you choose.


## Requirements <a name="requirements"></a>

We need very few things installed locally:

- [Docker](https://docs.docker.com/get-docker/) 19+ (a recent version)
- [docker-compose](https://docs.docker.com/compose/install/) v1.27+
- [VS Code](https://code.visualstudio.com/) 1.5+

And these Code extensions:

- [Docker](https://marketplace.visualstudio.com/items?itemName=ms-azuretools.vscode-docker)
- [Remote - Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers)


## Starting out <a name="starting-out"></a>

To make it easier for you to follow, I've set up a GitHub repository with all the code at <https://github.com/davidrios/example-docker-project>. I'll refer to specific commits so you can see the structure along the way.

We are starting the project from scratch, so the first thing we need is to sort out its structure. This is the one I like to use:

- Project root
    - some docker stuff and README
    - conf
        - project 1
        - project 2
        - misc
        - ...
    - code
        - project 1
        - project 2
        - ...

The next thing we need is to have some basic containers running to bootstrap the projects. We already know we need at least 3 containers from the project description: backend, frontend and database, so we need to create a base `docker-compose.yml`. But before that, one of the most useful things is to have a `.env` file, so you can have some amount of customization over the containers.

Let's create the initial `.env.template` file, that each user will make a local copy to customize:

```
LOCAL_USER_ID=1000
POSTGRES_PASSWORD=password
```

The `LOCAL_USER_ID` is used to work around Docker permission issues on Linux (more on that later). You set it with the uid of your local computer user as returned by the command `id -u`. You can just ignore it if you are on Mac.

After creating my local `.env` copy, it's time to define the basic `docker-compose.yml`:
<https://github.com/davidrios/example-docker-project/blob/2aac6eb151104b205461d025ca07647c44bc5d36/docker-compose.yml>

Things of note:

- I import the `.env` file at lines 3-5, and pass all defined variables as environment vars to all the other
  containers. You can see that at lines 10-11, 19-20, etc.
- I'm using Alpine images with major and minor versions specified, as per best practices. You may want or
  need to use an Ubuntu/Debian/etc based image, but you should aways choose specific major/minor versions.
- There is a volume defined for the PostgreSQL data, so that is preserved between runs.
- I run all projects I can as a standard (non root) user in the containers, and I like to create volumes
  for the home of that user on every container that I expect to log in and execute commands, that way I
  can have the shell history and other stuff preserved between rebuilds. That is an optional and personal
  preference, you can just remove those volumes. Be aware that, while using those volumes is convenient,
  they may have subtle effects on the overall reproducibility of the environment.
- For the backend and frontend, since they don't have any application configured yet, but I need them running
  anyway to be able to bootstrap the apps, I just tell them to run a very long `sleep`.

At this time we can navigate to the main project repository and just run:

    $ docker-compose up --build

You should see some messages from PostgreSQL about the initialization of the database and the three containers should keep running.

## The backend app <a name="the-backend-app"></a>

I like to execute the app as a regular user, and to avoid issues of permissions on Linux, that user need to have the same uid as my local computer user, that's the reason for the `LOCAL_USER_ID` in the `.env` file. To do that we'll add some helper scripts to be used inside the container:

- `base.sh`
- `run.sh`
- `enter.sh`

We'll add them to a directory `conf/backend/scripts`, as decided earlier. These scripts will only be executed on the dev environment, so they'll not be a part of the image.

We'll also create a `Dockerfile` for the container with some needed initial customizations. The compose file will be updated with the new image and mounts for the code and scripts. And the `code` directory will also be created and docker-compose will be executed again:

Check how the project has changed:

<https://github.com/davidrios/example-docker-project/commit/04ae1e95ca8dfe752e76a67fce5b9882847f2f8e>

```bash
## *** stop docker-compose ***
$ mkdir code
$ docker-compose up --build
```

Now we have the container running and ready to create the Django project. We use the `enter.sh` script to enter the container with the `venv` already activated and logged with the correct user. Now let's create the project:

```bash
## in another terminal at the root of the project folder:

## Create the database:
$ docker-compose exec -u postgres postgres-db createdb -T template0 -E utf8 backend

## Enter the backend container:
$ docker-compose exec backend /scripts/enter.sh

## Now inside the container:
$ cd /code
$ pip install --no-binary psycopg2 django psycopg2
$ django-admin startproject backend
$ cd backend

## Save the requirements:
$ pip freeze > requirements.txt

## *** Edit backend/settings.py to connect to the PostgreSQL db using the password from the environment and the host/dbname/user we already defined ***

$ python manage.py migrate
$ python manage.py createsuperuser
## *** insert desired login information ***

## Test the application runs:
$ python manage.py runserver

## *** Everything seems ok, stop the server and exit the container ***
```

Check the files added, with special attention to the database configuration:

<https://github.com/davidrios/example-docker-project/commit/8f47c701e6a3984a32021da360ae0c49a2d68a95#diff-192a1d9e9543969133c5449ace7b1169de815b39d539bc55fc1d168f32eedb7bR76-R85>

Now we need to set the application to run automatically with the container and to expose the port so we can access with our local browser. The installation of app packages will also be added to the image.

To be able to run locally on any port we choose and not conflict with other running stuff, we'll add that option to the `.env.template` file and the same line to our `.env`:

```
APP_PORT=8000
```

and update the docker stuff. Check the diff:

<https://github.com/davidrios/example-docker-project/commit/c9276155e56f1f8c0168bb81902e5e0f22ed0dad>

Now stop docker-compose and run it again, always with `--build`. The application should now be running and accessible at <http://localhost:8000> (or other port if you changed it).

This is it for now for the backend. VS Code configuration instructions will follow later in the guide.


## The frontend app <a name="the-frontend-app"></a>

This one is pretty similar to the backend. We'll create the same three helper scripts, with specific changes for the new environment, and customize the image a bit.

At this time we'll also add a `.dockerignore` file, so that Docker doesn't have to copy useless stuff everytime it has to build an image. That'll speed up the process a lot if you already have a big project.

Take a look at the changes:

<https://github.com/davidrios/example-docker-project/commit/bbb2c6180437c8e1ae5b3b0ca2121e78dc1250af>

```bash
## *** We stop docker-compose and start it again ***
$ docker-compose up --build
```

We bootstrap the Quasar project the same way:

```bash
$ docker-compose exec frontend /scripts/enter.sh
$ yarn global add @quasar/cli
$ cd /code
$ ~/.yarn/bin/quasar create frontend
## *** choose quasar options ***
## Pick at least ESLint because it's used later.
## I'll also pick TypeScript to demonstrate the great VS Code support later
## And finally choose an ESLint preset you wanna use on your project. I'll stick with standard here
## Let Quasar install the packages with yarn and get out of the container
```

Now we'll configure the frontend container to start Quasar in dev mode automatically and export the port so that we can access it.

Here's the resulting changes:

<https://github.com/davidrios/example-docker-project/commit/73e3b55df164ea2e1477cb51a8d591299c5f4643>

Note that we just moved the export port from the backend to the frontend container, we'll fix that in a bit.

```bash
## *** We stop docker-compose and start it again ***
$ docker-compose up --build
```

The Quasar app should now be running and accessible at <http://localhost:8000> (or other port if you changed it).


## A simple nginx proxy <a name="nginx-proxy"></a>

To make our lives easier, we'll create a simple but flexible nginx proxy in front of everything. We need to use a custom config file, and for maximum flexibility we'll process the config and interpolate with the env vars before starting the server.

These are the resulting changes:

<https://github.com/davidrios/example-docker-project/commit/b07c83199fd8d4b1cd4dfce1e830a19e76b64543>

We can now access the frontend app at <http://localhost:8000>, the Django admin at <http://localhost:8000/admin> and nginx will also proxy all requests to `/api*` to the backend app.


## VS Code for the win <a name="vscode"></a>

Now that we have our whole project well organized and running isolated from our local machine, we need to start coding it. Wouldn't it be awesome if we could have the same consistent and pleasant experience while coding as well? That's when VS Code comes to the rescue!

We'll use the remote containers extension to run VS Code directly inside the app containers, so it'll have access to the environment exactly as it runs and we can install extensions without affecting our local installation.

We will be using a directory named `code/vscode` to put related stuff, just so it is already available inside the containers because of the `/code` mount. You could put it in another directory, but then you would have to setup another mount for each container.


### Configuring for the backend <a name="vscode-backend"></a>

First create the directory `code/vscode/backend` and add the following files:

- `backend.code-workspace`: Replace backend with the real nome of your project, so it's easier to pick the Code windows apart when navigating.
- `flake8.ini`: Where we put the linter settings for Python. We'll be using flake8 instead of pylint, since it is much faster and I like it better :).
- `.devcontainer.json`: Tells Code how to setup the container instance

It's also necessary to edit the container `base.sh` script, so it installs necessary packages after any rebuild and also to start Python in debug mode. As a nice touch we'll also configure the server to autoreload when any file changes using [Watchdog](https://pypi.org/project/watchdog/), since the Code debugger extension for Python doesn't suport Django's autoreload. As a bonus, watchdog uses much less CPU than Django's default autoreloader.

I've gone ahead and added our first test API.

Check it out:

<https://github.com/davidrios/example-docker-project/commit/33cb5d775b88d8b953ba690c17e1359dd5fa34a5>

Now all we have to do is stop compose and run it again, all should be running and ready. To start working with the code we open a new Code window (`Ctrl+Shift+N`) and go to `File > Open Folder` and open the `<PROJECT>/code/vscode/backend` directory. Right away a notification should appear, you just click the `Reopen in Container` button. You can also open the command palette (`Ctrl+Shift+P`) and type `reopen in container` and pick `Remote-Containers: Reopen in container`. Then, it'll show a notification that it's installing the Code server and after a few moments it should open the project, and there will be an indication at the status bar that you are inside the container, like this:

![](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/d8a9mwayw50e7f8leqo1.png "Project opened")

Then you will have all editor goodies, like this:

![](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/rnma2gluz7tsmkg6a6p9.png)

And to debug, you just click the gutter to add a breakpoint, or press `F9` at the desired line, like this:

![](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/6iy71kxn27d8838r74h2.png)

Then go to the `Run` view clicking at the side, and click the little green "play" button. The status bar should turn to orange to indicate the debugger is connected. If you have a breakpoint, like I did here, and navigate to the new API in the browser, you will be greeted with glorious debugging goodness, like this:

![](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/fppt8xkui449cavm18xx.png)

Each time you change some file the app will also reload, like this:

![](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/66kmx40xrhsnkhiyyx18.png)

And if you need to run any Django administration commands, you can do so easily with the integrated terminal. Just open the menu `Terminal > New Terminal` (`Ctrl+Shift+<BACKTICK>`), like this:

![](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/apy8i292493id3w7x254.png)

If you find that the terminal did not have the venv activated, just click the `trash` icon to kill it and open it up again.


### Configuring for the frontend <a name="vscode-frontend"></a>

The configuration here is similar to the above. First create the directory `code/vscode/frontend` and add the file `.devcontainer.json`. I'm not configuring a project file here because there's no other folder besides the frontend project and to also use the settings files Quasar already creates when initializing the project. You could very well move everything to a project file just like we did with the backend.

Here're the results:

<https://github.com/davidrios/example-docker-project/commit/644b7547322d903f7e4b33454b787e5d1d4c6f49>

You don't even need to rerun docker-compose, just open a new Code window, open the directory `code/vscode/frontend` and click the `Reload in Container` button. After opening the project, click the `ESLint` button at the status bar and click to `allow` when the dialog appears. No you will have a fully configured editor, like so:

![](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/hu4cin7flkrlbmaimg74.png)

You get even autocomplete for things like Quasar components!

If you wand to use the quasar-cli to do things like create a component, you just open the inbuilt terminal clicking on menu `Terminal > New Terminal` (`Ctrl+Shift+<BACKTICK>`), like this:

![](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/1r3aj6tl1zm31y8bddd7.png)

Now its time to debug!

To do that, we create a `launch.json` with debug tasks for VS Code. This is not so trivial for some reasons:

- VS Code has two Chrome debuggers, one newer that ships with it and the old one you can install from the marketplace. The problem is the newer is better, but for some reason, and I suspect it has to do with the remote container, it can't launch Chrome instances. The old one can launch instances, but is not so good. So to solve our problems we can use both!
- Since we have a custom port defined in our `.env` file, we need some way to pass that to the debug task. Unfortunately the debug extensions runs at the local editor, not the remote one, so they don't have access to env vars. To solve that I added a little prompt when running the task that asks which port the app is running at, with the default from the env template for convenience.

The added tasks file:

<https://github.com/davidrios/example-docker-project/blob/6e5195fd13d2aea79b1a31c265ddf60808d9a77e/code/frontend/.vscode/launch.json>

We have three tasks which you will use depending on your browser:

- `Attach to Chrome`: This task uses the newer debugger to attach to a running Chrome instance with remote debugging enabled, and you don't need to install anything else. [Check here how to run Chrome with remote debugging enabled](https://blog.chromium.org/2011/05/remote-debugging-with-chrome-developer.html)
- `Launch Chrome`: This one launches an instance of Chrome with remote debugging already enabled and using a new profile, so it doesn't affect your personal one. To use this task you need to have the [Debugger for Chrome extension installed](https://marketplace.visualstudio.com/items?itemName=msjsdiag.debugger-for-chrome).
- `Launch Firefox`: And this launches an instance (or attaches to one if running) of Firefox with remote debugging enabled and also using a new profile. To use this task you need to have the [Debugger for Firefox extension installed](https://marketplace.visualstudio.com/items?itemName=firefox-devtools.vscode-firefox-debug).

There's one more thing we can do to make our lives better while debugging. The default webpack config doesn't generate detailed sourcemaps, which makes debugging a pain and pretty much impossible to set breakpoints in transpiled files. We fix that by adding `devtool: 'eval-source-map'` to its settings.

Even then, for transpiled files like `.vue` files, it generates a lot of variants of the same file with different hash names for each of the intermediate results, which is kind of annoying, so we fix that too by customizing the `output.devtoolModuleFilenameTemplate` function.

Since this project is in Quasar, we do that the way its supposed to, by editing `quasar.conf.js`.

It ends up looking like this:

<https://github.com/davidrios/example-docker-project/commit/1208e30bd280179df0780add22430af3a54c6c30>

Now you can run the debugger by going to the `Run` view, selecting a task and clicking "play":

![](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/3n67820af4t7c7npmz0x.png)

Try adding some `console.log`s and/or setting a breakpoint, running debug mode and using the app, then you should see something like this:

![](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/sfbquglbieoj4m8bmdt3.png)


## Addendum <a name="addendum"></a>

I'm not a huge fan of doing too much inside the code editor, especially things which are much more flexible to do in the terminal, that's why I don't use the Code Docker extension to manage/run docker-compose for example. I also think it works better that way when you have a project with several containers with different apps, like in this guide.

Since we are managing the compose flow manually, sometimes VS Code can show you this notification:

![](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/l5wl8yn6nafoon3m7vo6.png)

I recommend you to just click `Ignore`. If you need to rebuild things, just stop the compose running at the terminal and execute `docker-compose up --build` again.


## Conclusion <a name="conclusion"></a>

There are a lot of things to improve and different choices you could make for the various pieces, these are the ones I made and that worked the best for me.

Do note that this whole configuration is better suited to individuals and small teams, there are other, often mutually-exclusive, requirements for larger teams.

This is all very development focused, so the container images are not real ones that you can use to deploy, but that is doable with a little more work.

Thanks for reading!