Dockerize your projects in Visual Studio Code

Piethein Strengholt
7 min readSep 8, 2021

The last couple of weeks I used my evening time trying to hack around VS Code to create a consistent workflow environment. While at first glance this seems to be easy, there are a number of considerations you need to take into account when working on multi-component applications. The project, which I used is hosted here: https://github.com/pietheinstrengholt/rssmonster/

Installation

This guide combines Docker and Visual Studio Code for creating consistency across your development environment. Both are required to be installed to use this guide. The guide is written for a Node Express environment with VueJS frontend, but the steps can be modified for whatever environment is used.

After installing, make sure the Remote — Containers plugin is installed. This allows you to containerize your entire development environment.

Setup

The first thing you need to do is setting up your configuration. In the root directory of your project, create a folder called .devcontainer. This location will be used for storing all your settings.

Next, you need create a devcontainer.json file in your .devcontainer folder. This file instructs VS Code what runtime and additional configuration to be used. Important to mention here is that I’m using a multi container application, so therefore we will be using a docker-compose.yml, instead of Dockerfile. The services are defined as app (Express backend), client (VueJS frontend) and db (MySQL). I’ve provided the credentials for accessing the database relatively quickly. Lastly we defined the ports to be used for testing and debugging. These ports correspond with our services.

// Update the VARIANT arg in docker-compose.yml to pick a Node.js version: 10, 12, 14 
{
"name": "RSSMonster",
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"runServices": ["app", "client", "db"],
"workspaceFolder": "/workspace",
// Set *default* container specific settings.json values on container create.
"settings": {
"sqltools.connections": [{
"name": "Container database",
"driver": "MySQL",
"previewLimit": 50,
"server": "localhost",
"port": 3306,
"database": "rssmonster",
"username": "rssmonster",
"password": "password"
}]
},
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
"dbaeumer.vscode-eslint",
"mtxr.sqltools"
],
// Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [3000, 3306, 8080],
// Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
"remoteUser": "node"
}

After you’ve created your devcontainer.json file, it’s time to define the docker-compose.yml file. The application in this example uses three different services. Important are the environment variables which need to be configured here for deploying and configuring the database. Another point of attention is the mysql-data line. This is needed for storing our database locally, instead of within a container.

version: '3'services:
app:
build:
context: .
dockerfile: server.dockerfile
args:
# [Choice] Node.js version: 14, 12, 10
VARIANT: 12
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: 1000
USER_GID: 1000
volumes:
- ..:/workspace:cached

# Overrides default command so things don't shut down after the process ends.
command: sleep infinity
environment:
NODE_ENV: development
PORT: 3000
DB_USERNAME: rssmonster
DB_PASSWORD: password
DB_DATABASE: rssmonster
DB_HOSTNAME: db
ports:
- 3000:3000
# Uncomment the next line to use a non-root user for all processes.
# user: node
client:
build:
context: .
dockerfile: client.dockerfile
args:
# [Choice] Node.js version: 14, 12, 10
VARIANT: 12
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: 1000
USER_GID: 1000
volumes:
- ..:/workspace:cached

# Overrides default command so things don't shut down after the process ends.
command: sleep infinity
environment:
NODE_ENV: development
VUE_APP_HOSTNAME: http://app:3000/
db:
container_name: db
image: mysql:5.7
restart: always
volumes:
- ../mysql-data:/var/lib/mysql
command: [mysqld, --default-authentication-plugin=mysql_native_password, --character-set-server=utf8mb4, --collation-server=utf8mb4_unicode_ci, --innodb_monitor_enable=all]
environment:
MYSQL_ROOT_PASSWORD: "password"
MYSQL_DATABASE: "rssmonster"
MYSQL_USER: "rssmonster"
MYSQL_PASSWORD: "password"
MYSQL_ROOT_HOST: '%'
ports:
- 3306:3306

Also important to note here is that app and client use their own docker files. So you need to create two additional files within the .devcontainer folder:

client.dockerfile

# Update the VARIANT arg in docker-compose.yml to pick a Node version: 10, 12, 14
ARG VARIANT=14
FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:0-${VARIANT}
# Update args in docker-compose.yaml to set the UID/GID of the "node" user.
ARG USER_UID=1000
ARG USER_GID=$USER_UID
RUN if [ "$USER_GID" != "1000" ] || [ "$USER_UID" != "1000" ]; then groupmod --gid $USER_GID node && usermod --uid $USER_UID --gid $USER_GID node; fi

server.dockerfile

# Update the VARIANT arg in docker-compose.yml to pick a Node version: 10, 12, 14
ARG VARIANT=12
FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:0-${VARIANT}
# Update args in docker-compose.yaml to set the UID/GID of the "node" user.
ARG USER_UID=1000
ARG USER_GID=$USER_UID
RUN if [ "$USER_GID" != "1000" ] || [ "$USER_UID" != "1000" ]; then groupmod --gid $USER_GID node && usermod --uid $USER_UID --gid $USER_GID node; fi
# [Optional] Uncomment this section to install additional OS packages.
# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
# && apt-get -y install --no-install-recommends <your-package-list-here>
# [Optional] Uncomment if you want to install an additional version of node using nvm
# ARG EXTRA_NODE_VERSION=10
# RUN su node -c "source /usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}"
# [Optional] Uncomment if you want to install more global node modules
RUN su node -c "npm install -g nodemon"

For launching multiple containers in VS Code in parallel we need to setup an additional folder in the root of the project, called .vscode. In this folder we create two new files called launch.json and tasks.json. The launch.json holds the code for invoking a series of command for both the client and server.

launch.json

{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Launch Server",
"program": "${workspaceFolder}/server/app.js",
"cwd": "${workspaceFolder}/server",
"preLaunchTask": "Build server"
},
{
"type": "node",
"request": "launch",
"name": "Launch Client",
"program": "${workspaceFolder}/client/app.js",
"cwd": "${workspaceFolder}/client",
"preLaunchTask": "Build client"
}
]
}

tasks.json

{
// See https://go.microsoft.com/fwlink/?LinkId=733558
// for the documentation about the tasks.json format
"version": "2.0.0",
"tasks": [
{
"label": "Install server dependencies",
"type": "shell",
"command": "npm install",
"options": {
"cwd": "${workspaceFolder}/server"
}
},
{
"label": "Install client dependencies",
"type": "shell",
"command": "npm install",
"options": {
"cwd": "${workspaceFolder}/client"
}
},
{
"label": "Serve client",
"type": "shell",
"command": "npm run serve",
"group": {
"kind": "test",
"isDefault": true
},
"options": {
"cwd": "${workspaceFolder}/client"
},
"dependsOn": ["Install client dependencies"],
"isBackground": true
},
{
"label": "Deploy database",
"type": "shell",
"command": "./node_modules/.bin/sequelize db:migrate",
"options": {
"cwd": "${workspaceFolder}/server"
},
"dependsOn": ["Install server dependencies"]
},
{
"label": "Build server",
"dependsOn": ["Deploy database"]
},
{
"label": "Build client",
"dependsOn": ["Serve client"]
}
]
}

The tasks.json file in this example install all required packages. It also deploys the data schema via the sequelize command. Lastly it invokes the commands for starting the development environment for both client and server.

After creating the folder structure and files your project should look like the structure below. In my case all code is stored in the client and server folders.

After everything has been configured correctly, it is time to build and test. On the left bottom corner there is a new icon for Remote Containers. You can press this or hit F1. Choose the option to ‘Reopen in Container’.

If everything goes well you can see the containers being created and deployed. I recommend you to open the terminal pane to the see results.

After building all containers it is time to execute our tasks, as we defined them in the launch.json and tasks.json. Hit F5 to execute the first series of tasks for the server.

If everything goes well you must see your Express JS app running on port 3000.

For building the client you can use the Launch option at the bottom of the screen. Click ‘Launch Server’ and then choose Launch Client to also build the client.

After building the client you must have two containers running side by side.

Gotchas and workarounds

What I also learned during this project is that VueJS sometimes behaves strangely when running in a development container. It starts running on a different port, for example 8001 or 8081. Therefore I forced to always have it running on port 8080 using the following command lines in the packages.json of the Vue project.

"scripts": {
"serve": "vue-cli-service serve --host localhost --port 8080",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint",
"serve-secure": "vue-cli-service serve --https"
},

What I also noticed is that the environment variables not always work for VueJS. Therefore I added the following lines to the devcontainer.json. This ensures proper communication between the client and server, which I needed in my project.

"remoteEnv": {
"VUE_APP_HOSTNAME": "http://localhost:3000"
},

For WSL2, I learned that additional configuration is required for scanning for changes and newly created files. I used nodemon -L to fallback on the traditional method of inspecting for file changes.

"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "nodemon app.js",
"start-server": "node app.js",
"debug": "nodemon -L --inspect app.js"
},

Conclusion

Dockerizing your development environment is relatively new concept. I am personally excited about my results (see code) to see more possibilities, like running your entire project flow, for example, in Github Codespaces. This allows development from the browser and sharing your development environment with the rest of the world.

--

--

Piethein Strengholt

Hands-on data management professional. Working @Microsoft.