Coding Best Practices

VS Code Dev Containers on Windows: One Setup for the Whole Team

jrhaws October 26, 2026 20 min read

A new developer’s first day on a firmware project often goes to installing things: the right compiler version, CMake, a debugger, the formatter. A dev container moves all of that into the repository. Open the folder in VS Code, choose Reopen in Container, and you’re building with the same tools as everyone else.

This is a short guide to doing that on Windows, plus three things that make it work for a team: forwarding ports, keeping the extension list lean, and sharing one container setup across repositories with a git submodule.

TL;DR

  • Keep the repository in the WSL file system (~/git), not on C:\. File access is much faster, and file watching works.
  • forwardPorts makes a port inside the container show up as localhost on Windows. Use it instead of appPort.
  • List only the extensions the project needs in devcontainer.json. Your personal favorites go in the dev.containers.defaultExtensions user setting.
  • Put the container setup in its own repository, named <project>-devcontainer, and add it to each project as a submodule at .devcontainer.
  • Clone with --recurse-submodules, and set submodule.recurse so git pull keeps the container setup current.

The pieces on Windows

You need three things: WSL 2 with a container engine (Docker Desktop, or Docker Engine inside your distribution; my post on automating the WSL and Docker install has a script for both), VS Code, and two VS Code extensions, Dev Containers (ms-vscode-remote.remote-containers) and WSL (ms-vscode-remote.remote-wsl).

Here’s how they fit together once it’s running:

On Windows, the VS Code window runs on Windows while the code lives in WSL and the tools run in the containerThree nested layers. Windows runs the VS Code window and your browser. WSL 2 holds the repository in the Linux file system, at ~/git/sensor-fw. The dev container mounts that folder and runs the VS Code server, the extensions listed in devcontainer.json, and the toolchain. Port 8000 inside the container is forwarded to localhost:8000 on Windows.Where everything runsThe window is on Windows. The code is in WSL. The compiler and extensions are in the container.WindowsVS Code windowUI only, extensions run remotelyDocker Desktopor Docker Engine in WSLbrowserlocalhost:8000WSL 2 (Ubuntu)~/git/sensor-fwyour clone, on theLinux file systemdev container/workspaces/sensor-fwthe clone, bind-mountedVS Code servercpptools, cmake-toolstoolchaingcc-arm-none-eabi, cmakeforwardPorts: 8000
VS Code’s window runs on Windows, the clone lives in WSL, and the dev container runs the VS Code server, the extensions, and the toolchain.

The part people skip is where the clone lives. Clone from an Ubuntu terminal into your Linux home directory, then open it from there:

mkdir -p ~/git && cd ~/git
git clone --recurse-submodules git@github.com:acme/sensor-fw.git
cd sensor-fw && code .Code language: Bash (bash)

code . from WSL opens VS Code on Windows, connected to WSL. Then run Dev Containers: Reopen in Container from the Command Palette (F1).

Why bother? The container sees your files through a bind mount, and Docker’s guidance is that bind mounts from the Linux file system are much faster than ones from C:\ (/mnt/c in WSL). Linux tools also only get file-change notifications for files on the Linux side.

With Docker Engine inside WSL instead of Docker Desktop, VS Code’s docs say Dev Containers uses it when you open the folder through the WSL extension, as above.

A minimal dev container

The container definition is two files in .devcontainer. The Dockerfile pins the toolchain:

# Pin the base image version so every machine builds the same environment.
FROM mcr.microsoft.com/devcontainers/base:3.0.10-noble

RUN apt-get update \
    && apt-get install -y --no-install-recommends \
        build-essential \
        cmake \
        ninja-build \
        gcc-arm-none-eabi \
        libnewlib-arm-none-eabi \
        gdb-multiarch \
        clang-format \
    && rm -rf /var/lib/apt/lists/*Code language: Dockerfile (dockerfile)

The devcontainers/base images publish version tags like 3.0.10-noble alongside moving ones like ubuntu24.04. Pin the version and bump it on purpose.

devcontainer.json tells VS Code how to build and set up the container. Comments are allowed:

{
    "name": "Firmware toolchain",
    "build": { "dockerfile": "Dockerfile" },

    // Ports a program in the container listens on, opened on your machine as localhost.
    "forwardPorts": [8000],
    "portsAttributes": {
        "8000": { "label": "Docs preview" }
    },

    // Only what this project needs. Your personal favorites belong in your own
    // dev.containers.defaultExtensions setting, not here.
    "customizations": {
        "vscode": {
            "extensions": [
                "ms-vscode.cpptools",
                "ms-vscode.cmake-tools"
            ]
        }
    },

    "remoteUser": "vscode"
}Code language: JSON / JSON with Comments (json)

remoteUser runs your session as the image’s non-root vscode user, so files you create in the clone aren’t owned by root.

Forwarding ports

A server listening inside the container, say a docs preview on 8000, is invisible to your Windows browser until you forward it. forwardPorts does that every time the container starts, and the port shows up as localhost:8000 on Windows. portsAttributes gives it a readable name in VS Code’s Ports view, and its onAutoForward option decides what happens when VS Code forwards that port on its own: notify (the default), silent, or ignore.

An older property, appPort, looks similar but works differently:

  • forwardPorts tunnels the port through VS Code. To the program in the container, the connection comes from localhost, so a server that only listens on 127.0.0.1 still works.
  • appPort publishes the port when the container is created, the way docker run -p does. The program sees outside network traffic, so it has to listen on all interfaces, and changing the list means rebuilding the container.

The dev container spec recommends forwardPorts in most cases.

Only the extensions the project needs

Every extension in customizations.vscode.extensions gets installed into the container for every developer, on every rebuild. That list should be the tools the project depends on, like C/C++ support and CMake integration. It’s part of the project’s tooling, the same as the compiler version.

Don’t copy in everything you have installed locally. Your theme, your Vim keybindings, and your git history viewer are yours, and forcing them on teammates slows down every container setup. VS Code has a user setting for exactly this, and it follows you into every container you open:

"dev.containers.defaultExtensions": [
    "eamodio.gitlens",
    "vscodevim.vim"
]Code language: JSON / JSON with Comments (json)

If a base image or Feature installs an extension you don’t want, list it in devcontainer.json with a minus sign in front of its ID and it won’t be installed.

Share it with a submodule

Once one project has a working container, the next one wants the same, and copies of .devcontainer drift apart. The approach I use is to give the container setup its own repository, named with -devcontainer on the end, and pull it into each project as a submodule at .devcontainer:

One dev container repository shared by several projects, each pinning its own commit through a submoduleThe sensor-fw-devcontainer repository has three commits: f8bdfd8, af922c0 (adds cppcheck), and cd1b52d (adds lcov). Three projects include it as a submodule at .devcontainer. sensor-fw points at cd1b52d, motor-ctl at af922c0, and bootloader at f8bdfd8. Each project moves forward when someone runs git submodule update –remote and commits.One container repo, pinned per projectEach project records which commit of the shared setup it uses, and moves forward on purpose.sensor-fw-devcontainerf8bdfd8first versionaf922c0add cppcheckcd1b52dadd lcovbootloader/.devcontainermotor-ctl/.devcontainersensor-fw/.devcontainergit submodule update –remote .devcontainer, then commit, moves a project to the newest setup.
Every project records which commit of the shared container repository it uses, so a change only reaches a project when someone updates it there.

Add it to a project

The shared repository holds the two files at its root, plus a README. Adding it to a project is one command:

$ git submodule add git@github.com:acme/sensor-fw-devcontainer.git .devcontainer
Cloning into '/home/jon/git/sensor-fw/.devcontainer'...
done.
$ git status --short
A  .devcontainer
A  .gitmodules
$ cat .gitmodules
[submodule ".devcontainer"]
	path = .devcontainer
	url = git@github.com:acme/sensor-fw-devcontainer.git
$ git commit -m "Add shared dev container as .devcontainer"
[main 5e9fed7] Add shared dev container as .devcontainer
 2 files changed, 4 insertions(+)
 create mode 160000 .devcontainer
 create mode 100644 .gitmodulesCode language: Shell Session (shell)

That 160000 mode is the important part. The project stores only the commit of the shared repository it uses, which keeps it pinned until you choose to move it.

Clone with –recurse-submodules

A teammate cloning the project needs --recurse-submodules to get the container setup too:

$ git clone --recurse-submodules git@github.com:acme/sensor-fw.git
Cloning into 'sensor-fw'...
done.
Submodule '.devcontainer' (git@github.com:acme/sensor-fw-devcontainer.git) registered for path '.devcontainer'
Cloning into '/home/sam/git/sensor-fw/.devcontainer'...
done.
Submodule path '.devcontainer': checked out 'f8bdfd8dbfddf2b5cd2b71798561e70b81d806dc'Code language: Shell Session (shell)

Forget the flag and .devcontainer is an empty folder, so there’s no devcontainer.json for VS Code to find. One command fixes it:

$ ls -A .devcontainer
$ git submodule update --init
Submodule '.devcontainer' (git@github.com:acme/sensor-fw-devcontainer.git) registered for path '.devcontainer'
Cloning into '/home/sam/git/sensor-fw-plain/.devcontainer'...
done.
Submodule path '.devcontainer': checked out 'f8bdfd8dbfddf2b5cd2b71798561e70b81d806dc'Code language: Shell Session (shell)

Update on purpose

Say someone adds cppcheck to the shared Dockerfile and pushes it. Nothing changes in any project until someone moves that project forward:

$ git submodule update --remote .devcontainer
From github.com:acme/sensor-fw-devcontainer
   f8bdfd8..af922c0  main       -> origin/main
Submodule path '.devcontainer': checked out 'af922c0b15cc13be7b102424c6c789b56fdccc28'
$ git status --short
 M .devcontainer
$ git diff --submodule=log
Submodule .devcontainer f8bdfd8..af922c0:
  > Add cppcheck to the toolchain
$ git commit -am "Update dev container: add cppcheck"
[main 1c16181] Update dev container: add cppcheck
 1 file changed, 1 insertion(+), 1 deletion(-)Code language: Shell Session (shell)

git diff --submodule=log lists the commits you’re pulling in, which is what you want to read before committing. Then run Dev Containers: Rebuild Container to get the new tools.

On a teammate’s machine there’s one more catch. By default, git pull updates the commit the project points at, but not the files in .devcontainer:

$ git pull -q
$ git status --short
 M .devcontainer
$ git submodule update
Submodule path '.devcontainer': checked out 'af922c0b15cc13be7b102424c6c789b56fdccc28'Code language: Shell Session (shell)

If you see M .devcontainer right after a pull, that’s what happened. Setting submodule.recurse makes pull handle it for you:

$ git config --global submodule.recurse true
$ git pull
From github.com:acme/sensor-fw
   1c16181..115d455  main       -> origin/main
Fetching submodule .devcontainer
From github.com:acme/sensor-fw-devcontainer
   af922c0..cd1b52d  main       -> origin/main
Updating 1c16181..115d455
Fast-forward
 .devcontainer | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
Submodule path '.devcontainer': checked out 'cd1b52d47c41d103e09926269f4f8997feb4ab48'Code language: Shell Session (shell)

It doesn’t apply to git clone, though. A plain clone with submodule.recurse set still leaves .devcontainer empty, so --recurse-submodules still belongs in your onboarding docs.

Use a relative URL

If the shared repository lives next to your projects on the same server, give git submodule add a relative URL:

$ git submodule add ../sensor-fw-devcontainer.git .devcontainer
Cloning into '/home/jon/git/motor-ctl/.devcontainer'...
done.
$ cat .gitmodules
[submodule ".devcontainer"]
	path = .devcontainer
	url = ../sensor-fw-devcontainer.git
$ git config --get remote.origin.url
git@github.com:acme/motor-ctl.git
$ git config --get submodule..devcontainer.url
git@github.com:acme/sensor-fw-devcontainer.gitCode language: Shell Session (shell)

git resolves it against the project’s own remote, so HTTPS clones get the submodule over HTTPS and SSH clones over SSH, from the same .gitmodules.

Wrap-up

A dev container setup that holds up for a team:

  • Clone into WSL (~/git), open with code ., then Reopen in Container.
  • Pin the base image version and the toolchain in the Dockerfile.
  • Forward ports with forwardPorts, and label them.
  • Keep customizations.vscode.extensions to what the project needs; personal extensions go in dev.containers.defaultExtensions.
  • Share the setup as a <project>-devcontainer repository, added at .devcontainer, updated with git submodule update --remote and a commit.

How does your team share its development environment: a submodule, a template repository, or a prebuilt image? I’d like to hear what’s held up over time.


Keep going

The same pinned container that gives every developer the same tools can run your CI builds too, and that’s a big part of Shipping Firmware Like Software, the book I’m writing on embedded CI/CD.

If you’d like this set up for your team, the Embedded CI/CD Jumpstart builds a pinned container toolchain that your developers and your pipeline both use, with reproducible builds and off-target tests on top. See the Services page or get in touch.

Tags development container docker VS Code windows wsl

Leave a Reply

Your email address will not be published. Required fields are marked *