--- title: "How to Use Dev Containers for Reproducible Development" description: "Define a reproducible development environment in devcontainer.json, add Features and a Postgres service with Compose, and run it with the Dev Container CLI." category: "Development Tools" language: en source: https://cubepath.com/docs/development-tools/devcontainers-for-reproducible-development updated: 2026-09-25 --- # How to Use Dev Containers for Reproducible Development 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: ```bash docker run --rm hello-world ``` ```output 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: ```bash 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: ```bash sudo -E bash nodesource_setup.sh sudo apt install -y nodejs ``` Install the CLI globally: ```bash sudo npm install -g @devcontainers/cli ``` Check that both are available: ```bash node --version devcontainer --version ``` ```output 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: ```bash 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: ```bash nano .devcontainer/devcontainer.json ``` Add the following content: ```json { "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. > Note: `devcontainer.json` accepts 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`: ```bash 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: ```output {"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`: ```bash devcontainer exec --workspace-folder . python --version devcontainer exec --workspace-folder . python -c "import requests; print(requests.__version__)" ``` ```output 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](https://containers.dev/features). Open the configuration again: ```bash nano .devcontainer/devcontainer.json ``` Add a `features` block that installs Node.js 22 and the GitHub CLI: ```json { "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: ```bash devcontainer up --workspace-folder . --remove-existing-container ``` Verify the new tools: ```bash devcontainer exec --workspace-folder . bash -c "node --version && gh --version" ``` ```output 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: ```bash docker rm -f $(docker ps -aq --filter "label=devcontainer.local_folder=$HOME/myapp") ``` Create the Compose file: ```bash nano .devcontainer/compose.yaml ``` ```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: ```bash nano .devcontainer/devcontainer.json ``` ```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: ```bash devcontainer up --workspace-folder . ``` Confirm that the app container resolves and reaches the database by its service name: ```bash devcontainer exec --workspace-folder . python -c "import socket; socket.create_connection(('db', 5432), timeout=5); print('db reachable')" ``` ```output 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: ```bash mkdir -p .github/workflows nano .github/workflows/devcontainer.yml ``` ```yaml 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: ```bash 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.