University of Wales Trinity Saint David
ACCA7023 - Agile and DevOps

Developing Inside a Dev Container

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:

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.

.devcontainer/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"]
    }
  }
}
PropertyMeaning
imageThe container to develop in. Microsoft publishes images for common languages with git, a non-root user and other conveniences already set up.
forwardPortsPorts in the container to make available as localhost on your machine.
postCreateCommandA command run once, after the container is first created. Installing dependencies is the usual job.
customizations.vscode.extensionsExtensions 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.

.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/quotes mounts the parent of .devcontainer, which is your quotes folder, at /workspaces/quotes inside the container.
  • command: sleep infinity keeps 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_modules folders 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.

.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"
}
PropertyMeaning
dockerComposeFile and serviceWhich compose file to start, and which of its services VS Code should work inside
workspaceFolderThe folder inside the container that VS Code opens
shutdownActionStop every service, including the database, when you close the window
featuresSelf-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.
remoteUserWork 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?

Both containers run Linux. Only the CPU architecture can differ, for example ARM on Apple silicon.

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.

devcontainer.json
"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.

  1. Run Dev Containers: Add Dev Container Configuration Files and choose the template for your language. Read what it generates.
  2. Add the extensions you rely on, the ports your application uses and a postCreateCommand that installs dependencies.
  3. If your project needs a database or another service, switch to a compose-based dev container as in Task 4.
  4. Rebuild, and prove you can run and debug the project with nothing installed on your machine but Docker and VS Code.
  5. 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?

They serve different purposes. The production image should be as small and locked down as possible, holding only compiled output and production dependencies, running as a non-root user with no shell tools beyond what is needed. The dev container should be comfortable and complete, with compilers, debuggers, git, editor extensions, development dependencies and a live database, and it mounts the source so changes appear instantly. What they must share is the runtime and its major version, here Node.js 22, and ideally the same operating system family, so that code which works in development behaves the same in production. Pinning both to the same version is the point of the exercise.

Give two benefits and one drawback of requiring every developer on a team to use the dev container.

Benefits: a new developer is productive in minutes with one command instead of following a long set-up document, and everyone runs identical versions of every tool, which removes a whole class of works-on-my-machine bugs. The environment is versioned and reviewed in the repository alongside the code, and project environments cannot conflict with each other. Drawbacks: Docker must be installed and running, the images are large, and file access through bind mounts can be slower on Windows and macOS. Developers may also need to learn how to troubleshoot containers.

Your teammate edits a file inside the dev container, then runs Rebuild Container. Is their edit lost? Explain.

No. The project folder is bind mounted from the host disk into the container, so the edit was written to the real file on their machine. Rebuilding replaces the container and its writable layer, which only holds the installed tools and environment, and the volumes for the database and node_modules are kept. Only changes made elsewhere in the container, such as a tool installed by hand with apt, would be lost, which is why such tools belong in devcontainer.json instead.

Solution

Try the tasks above before revealing the solution.