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
sudoprivileges. - Docker Engine and the Docker Compose plugin installed from Docker's official repository, with your user added to the
dockergroup so you can rundockerwithoutsudo. - 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 Microsoftdevcontainers/pythonimages include a non-rootvscodeuser, Git and common shell tools. The1-3.12-bookwormtag 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.
Note
devcontainer.jsonaccepts comments (JSONC), so you can document choices for your team directly in the file.
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.
