Overview
Until now Docker has packaged applications to run. This lab uses it to develop. A dev container is a container that holds your whole development environment, including the language runtime, tools, editor extensions and supporting services such as a database. Visual Studio Code runs inside it, so your project works the same on every machine and a new team member is ready in minutes. You will start with a one-image dev container for hello-api, then build a full environment for the quotes application with a live database.
Learning Objectives
By the end of this lab you will be able to:
- Explain what a dev container is and how VS Code works with one
- Open a project in a dev container defined by a single image
- Read and write a devcontainer.json using image, features, customizations, forwardPorts and postCreateCommand
- Build a Compose-based dev container that includes a database
- Explain why dependency folders should live in a volume inside a dev container
- Add a dev container to your own project
Concept
Setting up a development machine is slow and fragile. Each project needs particular versions of a runtime, a database, command-line tools and editor extensions, and every developer installs them slightly differently. Projects that need different versions conflict on the same laptop. The bug that happens only on one person's machine is the result.
The Development Containers specification, started by Microsoft and now an open standard at containers.dev, solves this with a file called .devcontainer/devcontainer.json committed to the repository. It names an image or a Dockerfile or a compose file, and lists the tools, extensions and set-up commands the project needs. VS Code's Dev Containers extension reads it, builds and starts the container, mounts your project folder into it, and installs a small VS Code Server inside. The window on your desktop is then only the user interface. The terminal, the language services, the debugger and the extensions all run in the container.
Your source code stays on your own disk through a bind mount, so edits are saved normally and survive the container being rebuilt. The environment itself is disposable and reproducible. The same file also works in GitHub Codespaces, which runs the dev container on a cloud machine so that a project can be developed from a browser.
Tasks
Task 1 - Install the extension
Install Visual Studio Code if you do not have it. Open the Extensions view, search for Dev Containers by Microsoft (ms-vscode-remote.remote-containers) and install it. Make sure Docker Desktop is running.
The diagram shows what will happen when you open a project in a dev container.
Task 2 - The simplest dev container
Open your hello-api folder from the Writing Your First Dockerfile lab in VS Code. Create a folder inside it called .devcontainer, with a dot at the start, and in it a file called devcontainer.json.
// The simplest kind of dev container: one ready-made image.
{
"name": "hello-api",
"image": "mcr.microsoft.com/devcontainers/javascript-node:22",
"forwardPorts": [3000],
"postCreateCommand": "npm install",
"customizations": {
"vscode": {
"extensions": ["dbaeumer.vscode-eslint"]
}
}
}
| Property | Meaning |
|---|---|
image | The container to develop in. Microsoft publishes images for common languages with git, a non-root user and other conveniences already set up. |
forwardPorts | Ports in the container to make available as localhost on your machine. |
postCreateCommand | A command run once, after the container is first created. Installing dependencies is the usual job. |
customizations.vscode.extensions | Extensions to install inside the container for everyone who opens the project. |
Your .dockerignore from the Writing Your First Dockerfile lab already lists .devcontainer. The folder is for developers and has no place in the production image.
Task 3 - Reopen in the container
Open the Command Palette with F1 or Ctrl+Shift+P (Cmd+Shift+P on macOS), and run Dev Containers: Reopen in Container. The first time takes a few minutes, because the image is large. Click show log in the notification to watch it work.
When it finishes, the bottom left corner of the window shows Dev Container: hello-api. Open a terminal with Terminal, New Terminal from the menu and run these.
whoami
node --version
cat /etc/os-release | head -2
npm start
Success condition: the user is node, Node.js is version 22 even if your computer has no Node.js at all, and the operating system is Debian Linux, whatever your computer runs. When the server starts, VS Code shows a notification that port 3000 is available. Open http://localhost:3000 in your browser and the API answers.
Stop the server with Ctrl+C, change the greeting in server.js, and start it again. No image to rebuild. Your file is edited directly through the bind mount and Node.js in the container runs it. Finally run Dev Containers: Reopen Folder Locally to go back.
Task 4 - A dev container with a database
The quotes application needs Postgres while you develop it. A dev container can be defined by a compose file, so the database starts with it. Open your quotes folder in VS Code and create .devcontainer/compose.yaml.
# The services behind the dev container. "dev" is where VS Code runs.
services:
dev:
image: mcr.microsoft.com/devcontainers/typescript-node:22
volumes:
- ..:/workspaces/quotes:cached
# Linux packages live in volumes, so they never mix with packages
# installed on a Windows or macOS host in the same folders
- api-modules:/workspaces/quotes/quotes-api/node_modules
- web-modules:/workspaces/quotes/quotes-web/node_modules
environment:
DATABASE_URL: postgres://quotes:devpassword@db:5432/quotes
API_URL: http://localhost:3000
# Keep the container alive so VS Code can attach to it
command: sleep infinity
depends_on:
- db
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: quotes
POSTGRES_PASSWORD: devpassword
POSTGRES_DB: quotes
volumes:
- dev-db-data:/var/lib/postgresql/data
volumes:
dev-db-data:
api-modules:
web-modules:
Read it carefully.
..:/workspaces/quotesmounts the parent of.devcontainer, which is yourquotesfolder, at/workspaces/quotesinside the container.command: sleep infinitykeeps the container running with nothing to do, so VS Code can work inside it. You start the application yourself when you are ready.- The two
node_modulesfolders get their own volumes. The reason is the subject of Task 6. - The database password is in the file. That is acceptable here because this database exists only on your machine for development and holds nothing real.
Now create .devcontainer/devcontainer.json.
// A development environment for the quotes API, defined as code.
// VS Code builds it with Dev Containers: Reopen in Container.
{
"name": "Quotes development",
"dockerComposeFile": "compose.yaml",
"service": "dev",
"workspaceFolder": "/workspaces/quotes",
"shutdownAction": "stopCompose",
"features": {
"ghcr.io/devcontainers/features/github-cli:1": {}
},
"customizations": {
"vscode": {
"extensions": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"ms-azuretools.vscode-docker",
"cweijan.vscode-postgresql-client2"
]
}
},
"forwardPorts": [3000, 5173],
"postCreateCommand": "sudo chown node quotes-api/node_modules quotes-web/node_modules && npm ci --prefix quotes-api && npm ci --prefix quotes-web",
"remoteUser": "node"
}
| Property | Meaning |
|---|---|
dockerComposeFile and service | Which compose file to start, and which of its services VS Code should work inside |
workspaceFolder | The folder inside the container that VS Code opens |
shutdownAction | Stop every service, including the database, when you close the window |
features | Self-contained installers for extra tools, here the GitHub command-line tool. There are features for Docker, Python, Azure CLI, Terraform and hundreds more, listed at containers.dev/features. |
remoteUser | Work as the unprivileged node user, not root |
Task 5 - Develop the quotes application
Run Dev Containers: Reopen in Container. When it is ready, check the tools and that the database is reachable by its service name.
gh --version
echo $DATABASE_URL
npm run dev --prefix quotes-api
npm run dev uses tsx watch, which restarts the API every time you save a file. Open a second terminal with the + button and start the front end's development server too.
npm run dev --prefix quotes-web
Open http://localhost:5173, the Vite development server, which forwards /api to the API on port 3000 just as nginx does in production.
Success condition: the status line reads storage: postgres. Now add a field such as time: new Date() to the /api/health response in quotes-api/src/server.ts and save. The first terminal shows the API restarting within a second, and http://localhost:5173/api/health includes the new field. Edit quotes-web/src/style.css and the page updates without even a reload. You are developing a three-part application, with a real database, having installed nothing but Docker and VS Code.
Task 6 - Why node_modules lives in a volume
Some npm packages contain native programs compiled for one operating system. Vite depends on one called esbuild. Install on Windows and you get @esbuild/win32-x64. Install on Linux and you get @esbuild/linux-x64, and neither works on the other system.
Without the two volumes, node_modules would be inside the bind-mounted project folder, shared by your computer and the container. Whichever side installed last would break the other. While this lab was being written exactly that happened, and the container's npm ci replaced Windows packages that the host was still using. Mounting a volume over node_modules gives the container its own copy. Compare the two from a terminal inside the container and from one on your own machine.
# Inside the dev container
ls quotes-web/node_modules/@esbuild # linux-x64
# On your own computer, if you ever ran npm install there
ls quotes-web/node_modules/@esbuild # win32-x64 or darwin-arm64
Volumes are also much faster than bind mounts on Windows and macOS, which matters for folders holding tens of thousands of small files. The sudo chown at the start of postCreateCommand is needed because Docker creates new volumes owned by root, and the node user must be able to write to them.
A teammate on a Mac and you on Windows both use the same dev container. Which @esbuild package is in each of your containers?
Task 7 - Change the environment
The environment is code, so changing it is an edit and a rebuild. Add the Python feature to devcontainer.json, as if the team had decided to add some data analysis scripts.
"features": {
"ghcr.io/devcontainers/features/github-cli:1": {},
"ghcr.io/devcontainers/features/python:1": { "version": "3.13" }
},
Run Dev Containers: Rebuild Container.
python3 --version
Success condition: Python 3.13 is available. Your source code, which lives on your own disk, is untouched by the rebuild, and so is the database, which lives in its own volume. Commit .devcontainer and every member of the team gets the same change the next time they rebuild.
Task 8 - Add a dev container to your own project
Take the project you containerised in the Containerising Your Own Project lab.
- Run Dev Containers: Add Dev Container Configuration Files and choose the template for your language. Read what it generates.
- Add the extensions you rely on, the ports your application uses and a
postCreateCommandthat installs dependencies. - If your project needs a database or another service, switch to a compose-based dev container as in Task 4.
- Rebuild, and prove you can run and debug the project with nothing installed on your machine but Docker and VS Code.
- If your project is on GitHub, open it in a Codespace from the green Code button and confirm the same environment starts in the cloud.
Task 9 - Evaluate
The production Dockerfile and the dev container both describe an environment for the same application. Why are they different, and what must they keep in common?
Give two benefits and one drawback of requiring every developer on a team to use the dev container.
Your teammate edits a file inside the dev container, then runs Rebuild Container. Is their edit lost? Explain.
Solution
Try the tasks above before revealing the solution.