Devcontainers

Devcontainers1 are a fantastic way to create consistent and reproducible development environments using containerisation technology like Docker.

I also really like them because it keeps my local environment clean from the various projects that I experiment with, along with seeing how a project runs in Debian and Alpine Linux. These two are important for me because I need to make sure my dev environment matches the production environments for my projects as closely as possible.

Old world, new tech.
Old world, new tech, so many projects (some of them are even completed!).

This is a technical article that assumes prior knowledge of Docker and containerisation concepts.

If you aren’t familiar with these, I recommend checking out the Docker documentation and the What Are Containers? Docker Basics video .

High-level Requirements

To use devcontainers, you need the following:

  1. Operating system that supports Docker containers: Linux, macOS, or Windows.
  2. A Docker host, such as:
    • Docker Engine for Linux2.
    • OrbStack for macOS3
    • Docker Desktop for Windows4
  3. Visual Studio Code5 with the Remote Containers6 extension installed.

Consistent Project Setup

The focus of this article is a reference guide for setting up devcontainers in a way that can scale easily from a single container to a multi-service setup without drastically changing things around.

All my projects use git for version control and are hosted on GitHub or similar service.

Each project that I work on has the following minimal structure:

my-project/
├── .devcontainer/
│   ├── devcontainer.json
│   ├── Dockerfile.dev
│   └── ... supporting devcontainer files (if any) ...
├── src/
│   └── ... project code ...
├── .gitignore
├── .editorconfig
├── docker-compose.dev.yml
└── README.md

The next sections will explain the key files and their purpose.

README.md

This is the primary content file for the repository, it’s what appears when you visit the repository page on GitHub and I typically put information about how to get started developing in this file.

Since I come back to some projects after months, I make sure that I have a clear list of things to get started again and refresh my memory.

.devcontainer/devcontainer.json

This is the main configuration file for the devcontainer. What I do slightly differently here to all the “quickstart” guides you see online is that I use docker-compose.dev.yml to define the services that make up my development environment.

This is a battle-tested approach that I highly recommend - quite different to a “quick demo” that fails to be useful ten minutes after starting to try to work with it.

devcontainer.json template

Here is my minimal devcontainer.json template file:

{
    "name": "my project (devcontainer)",
    "dockerComposeFile": ["../docker-compose.dev.yml"],
    "service": "my-project-dev",
    "workspaceFolder": "/app",
    "features": {
        "ghcr.io/devcontainers/features/git:1": {},
        "ghcr.io/devcontainers/features/github-cli:1": {},
        "ghcr.io/devcontainers/features/docker-outside-of-docker:1.6.5": {},
        "ghcr.io/trunk-io/devcontainer-feature/trunk": "latest"
    },
    "forwardPorts": [],
    "customizations": {
        "vscode": {
            "extensions": [
                "ms-vscode-remote.remote-containers",
                "ms-azuretools.vscode-docker",
                "EditorConfig.EditorConfig",
                "eamodio.gitlens",
                "trunk.io"
            ],
            "settings": {
                "terminal.integrated.defaultProfile.linux": "bash",
                "git.enabled": true,
                "git.enableCommitSigning": true,
                "github.copilot": {
                    "enable": true
                }
            }
        }
    }
}

Here are some important notes about what is happening here:

  1. The dockerComposeFile property points to the docker-compose.dev.yml file in the project root.
    • This sets up the current working directory to point at the repository root
    • Ensures that the repository root (../) is mounted into the /app folder in the container and set as the project workspace.
    • The service property points to the specific service in the docker-compose.dev.yml file, which will be the development container that VS Code connects to.
  2. The features section includes several features that I find indispensable:
    • Git for version control.
    • GitHub CLI for interacting with GitHub from the terminal.
    • Docker outside of Docker to allow the devcontainer to use the Docker host on my MacBook via OrbStack. It also gives access to any services I run on my host, such as LLM inference.
    • Trunk for code quality, linting, syntax checking, and auto formatting.
  3. When I work with web-related projects, I will forward relevant ports in the forwardPorts array, such as 3000.
  4. The customizations section installs several VS Code extensions and configures several settings:
    • Extensions that I use frequently, including GitLens7 and extensions for managing containers and code consistency.
    • Settings to ensure that all git commits are signed using my GPG key and specify the default terminal profile.

docker-compose.dev.yml

The next file is the docker-compose.dev.yml file, which defines the services that make up my development environment.

Often, I will have a database service (Postgres) or a caching service (Redis) and rather than trying to manually define and run them, I have them all configured in the docker-compose.dev.yml file.

It also means I can copy that file to a production docker-compose.yml file with minimal changes for when I need to run something. Additionally, I can share my config, run it in codespaces, or on a shared devcontainer host.

Even if I don’t have any other services, I still define a minimal docker-compose.dev.yml file because it keeps things consistent across all my projects and works so well.

docker-compose.dev.yml template

Here is my minimal docker-compose.dev.yml template file:

services:
    my-project-dev:
        build:
            context: .
            dockerfile: .devcontainer/Dockerfile.dev
        volumes:
            - .:/app:cached
        command: sleep infinity

This file defines a single service called my-project-dev, which is built using the Dockerfile.dev file in the .devcontainer folder.

It also sets the context to the repository root (.) and mounts the repository root folder into the /app folder in the container. Additionally, it runs the sleep infinity command to keep the container running indefinitely.

Docker container best practices are to ensure there is a health check and that is defined inside the Dockerfile.dev file.

Another note here is that any commands in the Dockerfile.dev need to be run from the context of the repository root.

Dockerfile.dev

The final file is the Dockerfile.dev, which defines the actual development container image.

I usually base this off a standard Microsoft Container Registry devcontainer image that also has an equivalent base image in the same operating system.

EnvironmentBase Image
devcontainermcr.microsoft.com/devcontainers/base:debian-12
productiondebian:12-slim

For me, it’s really important to ensure that the base container has an arm64 version so that it runs natively on my MacBook without needing to emulate amd64 architecture.

And, I make sure that the base image is Debian-based so that it matches my production environments and has an amd64 version for deployment to cloud providers.

It is very rare (but it happens!) that there are issues between arm64 and amd64 architectures, however many issues are entirely avoided during development by using the same base OS.

Dockerfile.dev template

Here is my minimal Dockerfile.dev template file:

FROM mcr.microsoft.com/devcontainers/base:debian-12

SHELL ["/bin/bash", "-o", "pipefail", "-c"]

WORKDIR /app

USER root

# trunk-ignore(hadolint/DL3008)
RUN apt-get update \
    && apt-get install --no-install-recommends -y \
        curl \
        git \
        build-essential \
        # Install more packages as needed here
    && rm -rf /var/lib/apt/lists/*

# Perform additional root setup tasks here (if any) as required

USER vscode

# Perform additional user-level setup tasks here (if any) as required

HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 CMD [ "/usr/bin/whoami" ]

This file starts from the Debian 12-based devcontainer image, sets up the working directory, and installs some essential packages.

Note that I am using a non-standard name of Dockerfile.dev to clearly indicate that this is a development container Dockerfile and to avoid accidental “helpful automations” detecting and running this file as a production Dockerfile.

The reason that we have # trunk-ignore(hadolint/DL3008) is because Trunk creates a lint warning, suggesting the use of apt-get with pinned versions, meaning instead of writing curl one would write curl=7.88.1-10+deb12u14.

This is very good practice for production Dockerfiles to ensure reproducible builds and to avoid weird bugs being introduced into code that was otherwise working before.

However, for development containers, I prefer to always get the latest versions of packages, so I ignore that warning.

Then, the Dockerfile switches to the vscode user for development tasks and defines a simple health check.

The reason it uses the whoami command is to ensure that the container is running and responsive without assuming what may be running at any one time, especially if any servers are down for maintenance or an AI agent is doing some processing. This is a good proxy for a command that can always run successfully but will fail if the container is not healthy.

Running the devcontainer

There are many guides online on how to run the devcontainer, however the easiest way that I find is via the Visual Studio Code Command Palette.

Press Cmd+Shift+P (or Ctrl+Shift+P on Windows/Linux) to open the Command Palette, then type and select Reopen in Container....

Choose "Reopen in Container..." from the command palette.

Conclusion

Hopefully you can see my approach to devcontainers and how it’s an excellent way to keep a consistent and reproducible development environment.

Visual Studio Code with the minimal devcontainer repository.

You can find the template for this repository on my GitHub here: https://github.com/davidpirogov/minimal-devcontainer


  1. Devcontainers are Docker-based containerised development environments defined using configuration files, which specify the tools, libraries, and settings needed for a specific project. Read more about them here: https://code.visualstudio.com/docs/devcontainers/containers  ↩︎

  2. Docker Engine is the core software that enables containerisation on Linux systems. https://docs.docker.com/engine/  ↩︎

  3. OrbStack is a lightweight Docker and Kubernetes environment for macOS. https://orbstack.dev/  ↩︎

  4. Docker Desktop is an easy-to-install application for Windows (or macOs). https://www.docker.com/products/docker-desktop  ↩︎

  5. Visual Studio Code by Microsoft. https://code.visualstudio.com/  ↩︎

  6. The Remote - Containers extension provides support for Docker containers. https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers  ↩︎

  7. GitLens is a great VS Code extension shows great information about edit history within the editor. https://marketplace.visualstudio.com/items?itemName=eamodio.gitlens  ↩︎