Dev Containers describe a development environment as code: a devcontainer.json file in the repository declares the base image, extra tools, forwarded ports and setup commands, and any compatible tool (VS Code, the Dev Container CLI, GitHub Codespaces) builds the same container from it. In this tutorial you will create a Dev Container for a small Python project on an Ubuntu 24.04 server, add tools with Features, attach a PostgreSQL database with Docker Compose, and run everything from the command line so the same setup works in CI.

Prerequisites

To follow this tutorial, you will need:

  • A server or workstation running Ubuntu 24.04 LTS, for example a CubePath VPS, with at least 2 GB of RAM.
  • A non-root user with sudo privileges.
  • Docker Engine and the Docker Compose plugin installed from Docker's official repository, with your user added to the docker group so you can run docker without sudo.
  • Optionally, VS Code with the Dev Containers extension (ms-vscode-remote.remote-containers) if you want to open the environment in an editor.

Confirm Docker works for your user before you start:

docker run --rm hello-world
Hello from Docker!
This message shows that your installation appears to be working correctly.

Step 1 - Installing Node.js and the Dev Container CLI

The Dev Container CLI is the reference implementation of the Dev Containers specification and is distributed as an npm package. It needs a current Node.js release, which the Ubuntu 24.04 archive does not ship, so install Node.js 22 LTS from the NodeSource repository.

Download the NodeSource setup script and review it before running it:

curl -fsSL https://deb.nodesource.com/setup_22.x -o nodesource_setup.sh
less nodesource_setup.sh

Run it to add the repository, then install Node.js:

sudo -E bash nodesource_setup.sh
sudo apt install -y nodejs

Install the CLI globally:

sudo npm install -g @devcontainers/cli

Check that both are available:

node --version
devcontainer --version
v22.x.x
0.x.x

Step 2 - Creating a project and its devcontainer.json

Create a small project directory with a Python dependency file so the container has something to install:

mkdir -p ~/myapp/.devcontainer
cd ~/myapp
echo "requests==2.32.3" > requirements.txt

Now create the configuration file. Dev Containers look for it in .devcontainer/devcontainer.json at the repository root:

nano .devcontainer/devcontainer.json

Add the following content:

{
  "name": "myapp",
  "image": "mcr.microsoft.com/devcontainers/python:1-3.12-bookworm",
  "forwardPorts": [8000],
  "postCreateCommand": "pip install --user -r requirements.txt",
  "remoteUser": "vscode",
  "customizations": {
    "vscode": {
      "extensions": ["ms-python.python"]
    }
  }
}

What each key does:

  • image: the base image. The Microsoft devcontainers/python images include a non-root vscode user, Git and common shell tools. The 1-3.12-bookworm tag pins the image major version, Python 3.12 and Debian 12, so everyone gets the same stack.
  • forwardPorts: ports an editor such as VS Code forwards from the container to your machine.
  • postCreateCommand: runs once, after the container is created. Use it for dependency installation.
  • remoteUser: the user your commands and editor run as inside the container.
  • customizations.vscode.extensions: extensions VS Code installs inside the container. Other tools ignore this block.

Step 3 - Starting the container with the CLI

Build and start the environment. The --workspace-folder flag points at the folder containing .devcontainer:

devcontainer up --workspace-folder .

The first run pulls the image and runs postCreateCommand, which takes a minute or two. When it finishes, the last line is a JSON summary:

{"outcome":"success","containerId":"3f1c...","remoteUser":"vscode","remoteWorkspaceFolder":"/workspaces/myapp"}

Your project directory is bind-mounted at /workspaces/myapp, so edits on the host are visible inside the container immediately. Run commands inside the environment with devcontainer exec:

devcontainer exec --workspace-folder . python --version
devcontainer exec --workspace-folder . python -c "import requests; print(requests.__version__)"
Python 3.12.x
2.32.3

The requests import succeeds, which confirms postCreateCommand ran. On Linux hosts, the CLI also adjusts the vscode user's UID to match yours, so files created in the container are owned by your host user.

Step 4 - Adding tools with Features

Features are reusable, versioned install units published as OCI artifacts. They let you add tools without maintaining a custom Dockerfile. Browse the catalog at containers.dev/features.

Open the configuration again:

nano .devcontainer/devcontainer.json

Add a features block that installs Node.js 22 and the GitHub CLI:

{
  "name": "myapp",
  "image": "mcr.microsoft.com/devcontainers/python:1-3.12-bookworm",
  "features": {
    "ghcr.io/devcontainers/features/node:1": {
      "version": "22"
    },
    "ghcr.io/devcontainers/features/github-cli:1": {}
  },
  "forwardPorts": [8000],
  "postCreateCommand": "pip install --user -r requirements.txt",
  "remoteUser": "vscode",
  "customizations": {
    "vscode": {
      "extensions": ["ms-python.python"]
    }
  }
}

The :1 suffix pins the Feature's major version, so upstream breaking changes do not reach your team unexpectedly. Configuration changes only apply to a new container, so recreate it:

devcontainer up --workspace-folder . --remove-existing-container

Verify the new tools:

devcontainer exec --workspace-folder . bash -c "node --version && gh --version"
v22.x.x
gh version 2.x.x

If you need system packages that no Feature provides, add a .devcontainer/Dockerfile that starts FROM the same base image, and replace the image key with "build": { "dockerfile": "Dockerfile" }.

Step 5 - Adding a database with Docker Compose

Real projects usually need services such as a database. With Docker Compose, the Dev Container becomes one service in a Compose project, and the others start alongside it.

Remove the current container first so it does not linger after you switch to Compose:

docker rm -f $(docker ps -aq --filter "label=devcontainer.local_folder=$HOME/myapp")

Create the Compose file:

nano .devcontainer/compose.yaml
services:
  app:
    image: mcr.microsoft.com/devcontainers/python:1-3.12-bookworm
    volumes:
      - ..:/workspaces/myapp:cached
    command: sleep infinity
    depends_on:
      - db

  db:
    image: postgres:17
    restart: unless-stopped
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: myapp
      POSTGRES_PASSWORD: dev_only_password
    volumes:
      - postgres-data:/var/lib/postgresql/data

volumes:
  postgres-data:

The app service mounts the repository (one directory up from .devcontainer) and runs sleep infinity so the container stays up for you to work in. The db password is only for local development; never reuse it anywhere else.

Replace devcontainer.json so it points at the Compose file instead of an image:

nano .devcontainer/devcontainer.json
{
  "name": "myapp",
  "dockerComposeFile": "compose.yaml",
  "service": "app",
  "workspaceFolder": "/workspaces/myapp",
  "shutdownAction": "stopCompose",
  "features": {
    "ghcr.io/devcontainers/features/node:1": {
      "version": "22"
    }
  },
  "forwardPorts": [8000, "db:5432"],
  "postCreateCommand": "pip install --user -r requirements.txt",
  "remoteUser": "vscode"
}

service tells the tooling which Compose service is your development container, workspaceFolder must match the mount path in compose.yaml, and shutdownAction stops the whole Compose project when you close the editor.

Start it:

devcontainer up --workspace-folder .

Confirm that the app container resolves and reaches the database by its service name:

devcontainer exec --workspace-folder . python -c "import socket; socket.create_connection(('db', 5432), timeout=5); print('db reachable')"
db reachable

You can also list both containers with docker ps. Their names start with the Compose project name derived from your folder, for example myapp_devcontainer-app-1 and myapp_devcontainer-db-1.

Step 6 - Opening the environment in VS Code

The same configuration works in VS Code. On a local machine with Docker, open the project folder, run Dev Containers: Reopen in Container from the Command Palette (Ctrl+Shift+P), and VS Code builds the container, installs the listed extensions and opens a terminal inside it.

To use the Docker engine on your server instead of your laptop, first connect with the Remote - SSH extension, open ~/myapp on the server, and then run Dev Containers: Reopen in Container. This keeps heavy builds and databases off your local machine.

Useful commands from the Command Palette:

  • Dev Containers: Rebuild Container: apply changes to devcontainer.json, the Dockerfile or Features.
  • Dev Containers: Show Container Log: see build output when something fails.

Step 7 - Running the same environment in CI

Because the CLI only needs Docker and Node.js, CI can run your tests inside the exact environment developers use. Create a GitHub Actions workflow:

mkdir -p .github/workflows
nano .github/workflows/devcontainer.yml
name: tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - name: Install the Dev Container CLI
        run: npm install -g @devcontainers/cli
      - name: Start the dev container
        run: devcontainer up --workspace-folder .
      - name: Run tests
        run: devcontainer exec --workspace-folder . python -m pytest

Add pytest to requirements.txt for this to pass. Commit .devcontainer/ and the workflow so every clone and every CI run starts from the same definition.

Troubleshooting

permission denied while trying to connect to the Docker daemon socket: your user is not in the docker group, or you have not logged in again since adding it. Run sudo usermod -aG docker $USER, then log out and back in.

The build fails and the error is unclear: rerun with verbose logging to see each build and lifecycle step:

devcontainer up --workspace-folder . --log-level debug

Changes to devcontainer.json have no effect: an existing container is being reused. Recreate it with devcontainer up --workspace-folder . --remove-existing-container, or use Rebuild Container in VS Code.

db does not resolve inside the container: make sure devcontainer.json uses dockerComposeFile and service, not image. Only containers started from the Compose project share its network.

Conclusion

You now have a Dev Container defined in the repository that pins the base image and tools with Features, starts PostgreSQL through Docker Compose, and runs identically from the CLI, VS Code and CI. As next steps, add a .devcontainer/Dockerfile for system packages your project needs, publish a prebuilt image with devcontainer build --image-name to speed up startup, or add the lifecycle hook postStartCommand for tasks that must run every time the container starts.