Supercharge your dev containers with devconfigs

Devconfigs allow you to add shared VSCode debug launcher and task configurations to your dev containers. Automate common setup tasks like setting up git hooks, authenticating to your secrets management service, and launching a local version of your app, all on container startup. And when you update your devconfigs the changes automatically sync to each project when you open that dev container.

Why we created devconfigs

Our team at Stackfound uses the Dev Containers extension for VSCode to work on each of our projects inside dedicated development containers. This helps us avoid the dependency conflicts we would inevitably run into if we developed directly on our host OS. It also makes it easy to onboard new developers. They simply clone the project, which already has the dev container configuration setup, and launch the dev container.

We use VSCode’s launch.json and tasks.json files to add debug launcher and task configurations to each project. We configure our default build task to finish setting up our dev environment after launching a container. When working on Stackfound projects, we get a reminder after our container launches to use the CMD + SHIFT + B keyboard shortcut in VSCode to run the default build task. If I was working on a Python Flask project, for example, that default build task would invoke several different tasks sequentially:

  1. commit-hooks - installs git hooks to check our commits for secrets we shouldn’t be committing (using pre-commit)

  2. dotbot - sets up my dotfiles so everything is just how I like it

  3. Doppler - logs me into my Doppler account using the token from their host OS and sets my doppler project and config.

  4. Flask - launches a local version of the Flask app

  5. terminal - opens a fresh terminal window using the preferred shell (fish) from my dotfiles

Each task is neatly contained in its own terminal window so I can easily jump into the Flask app’s output if I need to see why the app isn’t working properly, or into the Doppler window if I need to double-check on what config was loaded.

All of this is powered through the .vscode/tasks.json file in this repository. We used to commit that file to our remote repository so that each developer who worked on the project could take advantage of the automatic setup provided by the tasks configured in tasks.json. The problem is, we work on a lot of projects, and so changing a task configuration means changing every single repository that uses that task. Which is, obviously, just not going to happen. And so our task configurations started sprawling, with different versions in each repo, and the developer experience was far from uniform across repos.

That’s where devconfigs come in.

With devconfigs, all of our debug launcher and task configurations are stored in Stackfound’s devconfigs repository, and we simply need to specify which ones we want to use for each project in our .devcontainer/devconfigs.yml file, like this:

repositories:
  stackfound: https://github.com/stackfound/devconfigs.git
dependencies:
launchers:
  stackfound:
    flask:
tasks:
  stackfound:
    dotbot:
      repo: https://github.com/stackfound/dotfiles.git
    flask:
      directory: flask
      port: 5051
    fish:

In this case, our project will be able to pull the latest version of the Stackfound flask debug launcher and the Stackfound flask, dotbot, and fish task configurations. The devconfigs CLI adds the configurations to the project’s .vscode/launch.json and .vscode/tasks.json files once the dev container is launched. So if we update those configurations in Stackfound’s devconfigs repo, each project that uses those configurations will also see the updates the next time a project dev container is launched.

Adding devconfigs to your project

A demo is worth a thousand words, so let’s walk through adding devconfigs to a sample Python Flask project. Feel free to clone the repository below if you would like to follow along.

git clone https://github.com/stackfound/flask-without-devconfigs.git

Step 1. Install the devconfigs CLI

brew tap stackfound/devconfigs
brew install devconfigs-cli

Step 2. Initialize your project to use devconfigs

# run this command in your project root directory
devconfigs init

Step 3. Reopen your project in a container

Step 4. Run the default build task (CMD + SHIFT + B) and watch the magic happen


The devconfigs init command modifies a few files in your repository to prepare it for using devconfigs.

First, a default .devcontainer/devconfigs.yaml file is created. This is the configuration file that devconfigs parses to pull in dependency check, debug launcher, and task configurations from the devconfigs repos you specify.

Next, the initializeCommand and postStartCommand attributes are updated in .devcontainer/devcontainer.json. The initializeCommand is automatically triggered before the dev container is built, and runs the devconfigs setup CLI command, which triggers devconfigs to parse the devconfigs.yaml file.

Finally, the .vscode and .devcontainer/devconfigs directories are added to .gitignore. The devconfigs repos you specified in the devconfigs.yaml file are pulled into the .devcontainer/devconfigs directory. The devconfigs CLI uses those repositories to build each particular dependency check, launcher, and task configuration inside the .vscode/launch.json and .vscode/tasks.json files. Since all of the files in these two directories will be overwritten by the devconfigs CLI each time we open the devcontainer, we don’t track these files with git.


Creating your own devconfigs

Your team’s workflow is likely going to be different than Stackfound’s, so you will probably want to use different devconfigs than we use. You may also need to use your own task or debug launcher configurations, so here’s how you can write and use your own devconfigs.

Step 1. Make a new devconfigs/ directory and run the devconfigs create CLI command from there

mkdir devconfigs
cd devconfigs
devconfigs create

Step 2. Update the sample dependency check, debug launcher, and task configurations according to your needs

Step 3. Create a new devconfigs repository wherever you keep your remote repos (GitHub, GitLab, etc)

Step 4. Add your remote url to your local devconfigs directory and push your changes

git remote add origin https://github.com/USERNAME/devconfigs.git


For simple tasks and debug launchers you just need to create a devconfigs/tasks/<task_name>/config.json or devconfigs/launchers/<launcher_name>/config.json file. Write the task or debug launcher configuration just like you would in the .vscode/tasks.json or .vscode/launch.json file. For example, here is the config.json file for the devconfigs create helloSimple task, which matches exactly what is seen in the .vscode/tasks.json file after devconfigs builds it.

{
    "label": "Hello simple",
    "type": "shell",
    "command": "echo Hello, simple world!",
    "presentation": {
      "reveal": "always",
      "panel": "new"
    }
}

If you need to modify the config.json file based on user input inside the devconfigs.yaml file, you should create a buildConfig.sh bash script that will build the config.json file. You can see an example of how this works in the devconfigs create helloOptions task.

#!/bin/bash

# your buildConfig.sh script needs to parse the options in the devconfigs.yaml
# file and then build and place a config.json file in the task directory, which can
# be accessed at the path stored in the provided $TASK_DIRECTORY variable.
# We typically use a _config.json placeholder file that is mostly complete, and edit
# the file contents using jq to create our config.json file.

options=($(yq e '.tasks.'$repository.$task' | to_entries | .[] | .key' $DEVCONFIGS_YML))
for option in "${options[@]}"; do
    option_value=$(yq e '.tasks.'$repository.$task.$option $DEVCONFIGS_YML)
    case $option in
        # add cases here to handle each option specified in devconfigs.yaml
        name)
            NAME=$option_value
            ;;
        title)
            TITLE="$option_value "
    esac
done

# In this case, we build the task command with the environment variables we saved
# from each option above

TASK_COMMAND="echo Hello, $TITLE$NAME"
cat $TASK_DIRECTORY/_config.json > $TASK_DIRECTORY/config.json
jq -r --arg val "$TASK_COMMAND" '.command |= $val' $TASK_DIRECTORY/_config.json > $TASK_DIRECTORY/config.json
# devconfigs/tasks/buildOptions/_config.json
{
    "label": "Hello with options",
    "type": "shell",
    "command": "",
    "presentation": {
        "reveal": "always",
        "panel": "new"
    }
}

Let's build great things

We hope you’ll find devconfigs as useful as we do. If you think of ways to make devconfigs even better, please feel free to open a pull request for the devconfigs-cli. We would be happy to see how we can make this tool even more useful!