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 onC:\. File access is much faster, and file watching works. forwardPortsmakes a port inside the container show up aslocalhoston Windows. Use it instead ofappPort.- List only the extensions the project needs in
devcontainer.json. Your personal favorites go in thedev.containers.defaultExtensionsuser 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 setsubmodule.recursesogit pullkeeps 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:
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:
forwardPortstunnels the port through VS Code. To the program in the container, the connection comes fromlocalhost, so a server that only listens on127.0.0.1still works.appPortpublishes the port when the container is created, the waydocker run -pdoes. 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:
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 withcode ., 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.extensionsto what the project needs; personal extensions go indev.containers.defaultExtensions. - Share the setup as a
<project>-devcontainerrepository, added at.devcontainer, updated withgit submodule update --remoteand 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.
Leave a Reply