"Works on my machine" is the oldest bug in data engineering. It happens because dbt, Databricks Connect, Python versions, Java versions (for PySpark), and platform CLIs drift independently across laptops. Devcontainers fix it by declaring the entire environment as code and rebuilding it from that declaration on every checkout. DevPod extends devcontainers to remote compute, so the same declaration runs on your laptop, a beefy EC2 VM, or a shared devpod cluster.
By the end of this guide you will have:
- A working
.devcontainer/devcontainer.jsonfor a Python + dbt + Astro project. - VS Code reopening the project in the container automatically.
- DevPod running the same devcontainer against a remote backend.
- A clear mental model of what belongs in the devcontainer, what belongs in the repo's venv, and what belongs in personal dotfiles.
1. Why devcontainers
Three failures disappear the moment a repo ships a devcontainer:
- Python-version drift. Engineer A is on 3.11.4 via pyenv, engineer B is on 3.12.3 via uv, CI runs 3.12.1. Tests pass for A, fail for B, pass for CI. The devcontainer pins the version.
- JDK-version drift. PySpark needs Java. Java 11, 17, and 21 all work for some versions of Spark and break for others. The devcontainer pins the JDK.
- CLI-version drift. The
dbtordatabricksCLI on the laptop differs from the one in CI. Commands behave subtly differently; bugs are unreproducible.
Devcontainers replace the setup README ("install Python 3.12, then install JDK 17, then set JAVA_HOME, then…") with an image that does all of it.
Note
A devcontainer is not a production deployment artifact. It is a development environment. The same repo can ship a devcontainer for engineering and a production Docker image for deployment; they are different concerns.
2. Authoring devcontainer.json
Create .devcontainer/devcontainer.json at the repo root. Start with a base image and add features:
{
"name": "churn-analytics",
"image": "mcr.microsoft.com/devcontainers/python:3.12",
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:2": {},
"ghcr.io/devcontainers/features/aws-cli:1": {},
"ghcr.io/devcontainers/features/node:1": {},
"ghcr.io/customink/codespaces-features/astro-cli:1": {}
},
"postCreateCommand": "uv sync && dbt deps && pre-commit install",
"customizations": {
"vscode": {
"extensions": [
"ms-python.python",
"ms-python.vscode-pylance",
"charliermarsh.ruff",
"dbtLabsInc.dbt",
"dorzey.vscode-sqlfluff",
"samuelcolvin.jinjahtml",
"redhat.vscode-yaml",
"databricks.databricks",
"eamodio.gitlens",
"GitHub.vscode-pull-request-github"
],
"settings": {
"python.defaultInterpreterPath": "/workspaces/churn-analytics/.venv/bin/python",
"editor.formatOnSave": true
}
}
},
"forwardPorts": [8080, 8793],
"remoteUser": "vscode"
}
Key fields:
image: the base. Microsoft'sdevcontainers/python:3.12is a solid default — it includes Git, SSH, locale, and thevscodeuser. Alternatives:devcontainers/base:ubuntufor more control, or a custom image.features: modular add-ons (Docker, AWS CLI, Node, Astro CLI). Hundreds of features exist; pick only the ones this project actually needs.postCreateCommand: runs once after the container is built. Good place foruv sync,dbt deps,pre-commit install.customizations.vscode.extensions: the Extension Pack gets installed inside the container.forwardPorts: if the project runs local Airflow on 8080 and Postgres on 8793, expose them.remoteUser: run as an unprivileged user. Never run a devcontainer as root.
Tip
Put the heavy postCreateCommand work behind postStartCommand instead if you want fast restart times. postCreate runs on build; postStart runs on every container start.
3. Open in container
- Install the Dev Containers extension (
ms-vscode-remote.remote-containers). - Open the repo in VS Code.
- Click the green button in the lower-left, then Reopen in Container. Or run Dev Containers: Reopen in Container from the command palette.
VS Code builds the image, starts the container, and reconnects the editor to it. Terminals, the Git client, and extensions all run inside the container. First build takes 2–5 minutes. Subsequent opens take seconds.
Important
The repo is bind-mounted into the container at /workspaces/<repo-name>. Edits from the editor land on the host filesystem in real time. Do not run rm -rf /workspaces/... from inside the container thinking it is a disposable sandbox; it is your real checkout.
4. Post-create commands
The postCreateCommand runs one bash command. For anything non-trivial, point it at a shell script:
"postCreateCommand": "bash .devcontainer/post-create.sh"
#!/usr/bin/env bash
set -euo pipefail
uv sync --frozen
uv run dbt deps
uv run pre-commit install
uv run pre-commit install --hook-type commit-msg
echo "Environment ready."
Keep the script short and idempotent. Engineers will run it multiple times (rebuilds, feature additions). A script that fails on second run is a script that costs trust.
5. Secrets inside a devcontainer
Secrets are the devcontainer's biggest footgun. Three patterns, only one is safe:
Safe: host-to-container passthrough
Mount ~/.aws/, ~/.databricks/, and ~/.dbt/ from the host:
"mounts": [
"source=${localEnv:HOME}/.aws,target=/home/vscode/.aws,type=bind,readonly",
"source=${localEnv:HOME}/.databricks,target=/home/vscode/.databricks,type=bind,readonly",
"source=${localEnv:HOME}/.dbt,target=/home/vscode/.dbt,type=bind,readonly"
]
Secrets live on the host, are shared read-only into the container, and never ship in the image.
Less safe: devcontainer env vars
"containerEnv": {
"DATABRICKS_HOST": "${localEnv:DATABRICKS_HOST}",
"DATABRICKS_TOKEN": "${localEnv:DATABRICKS_TOKEN}"
}
The env vars get copied from the host into the container at start. Acceptable for OAuth tokens. Not acceptable for long-lived credentials — they leak into docker inspect and any process that reads /proc/<pid>/environ.
Unsafe: baking secrets into the image
"args": { "DBT_TOKEN": "dbt_xxx..." }
Never. The secret ends up in the image layer, the container registry, and the devcontainer JSON in Git. Rotate immediately if you find this.
Warning
A devcontainer commits to the repo. Anything hard-coded in devcontainer.json is public to everyone with repo read access. Never write a literal secret there.
6. Features vs. Dockerfile
Two ways to add tools to the container:
- Features (
ghcr.io/devcontainers/features/...). Community-maintained, composable, declarative. Use them whenever a feature exists. - Custom Dockerfile. Full control. Use it when you need a package that no feature provides.
For a custom Dockerfile, replace image with build.dockerfile:
{
"name": "churn-analytics",
"build": {
"dockerfile": "Dockerfile",
"context": ".."
},
"features": { ... }
}
Keep the Dockerfile minimal. If you find yourself installing Python, Java, and ten tools, lean on features instead.
7. Performance on macOS
Docker Desktop's file-sharing on macOS is famously slow. Large node_modules directories, .venv trees, and target/ folders can kill the editor. Mitigations:
- Use named volumes for caches. Mount
/home/vscode/.cache/uvas a named volume; cache survives rebuilds and doesn't cross the file-share boundary.
"mounts": [
"source=uv-cache-${containerWorkspaceFolderBasename},target=/home/vscode/.cache/uv,type=volume"
]
- Use Colima or OrbStack instead of Docker Desktop. Both have materially faster file-sharing.
- Move
.venvinside a named volume if it is large enough to matter.
Tip
If a devcontainer feels slow, check the file-sharing first. time ls -R . on a 100-file directory should take milliseconds. If it takes seconds, your file-share is the bottleneck.
8. DevPod: running devcontainers remotely
DevPod takes the same devcontainer.json and runs it anywhere:
- Local Docker (same as Dev Containers extension).
- A remote VM (EC2, GCP, Azure).
- A Kubernetes cluster.
- A company-managed devpod fleet.
Install DevPod (brew install devpod, or the desktop app). Add a provider:
devpod provider add aws
devpod provider use aws
Create a workspace against the repo:
devpod up github.com/your-org/churn-analytics
DevPod provisions an EC2 VM, clones the repo, builds the devcontainer on the VM, and connects VS Code to it via SSH. The laptop becomes a thin client; the environment runs where the compute is.
When DevPod wins
- Data is too big to process locally. A 200 GB Parquet read on a laptop is hopeless. On a
r5.8xlargeit is minutes. - Laptops are thin. Junior engineers on M1 Airs get the same 32 GB / 16 vCPU experience as the architects.
- The engineer works from anywhere. Coffee shop WiFi cannot compile a large project; a remote devpod compiles it in seconds and streams the editor state back.
- Security requires the data stay off the laptop. The devpod lives in the VPC; the laptop only sees the editor UI.
When DevPod loses
- Latency-sensitive typing. Sub-50 ms keystroke latency matters for fast typists. Transatlantic devpods feel bad. Pick a region near you.
- Offline work. Flying or hiking without WiFi? Local-only devcontainer or nothing.
- Solo hobby projects. Paying for a VM to edit a 1 kLOC script is silly.
9. Teardown
Devcontainers and devpods accumulate. Every project is a new container; the disk fills.
# Dev Containers extension
docker ps -a --filter "label=devcontainer.local_folder" --format "{{.ID}}\t{{.Names}}"
docker rm -f <container-id>
# DevPod
devpod list
devpod delete <workspace-name>
DevPod's auto-stop is your friend: idle workspaces shut down after a configurable inactivity period. Turn it on.
10. The paved path
A paved-path devcontainer strategy looks like:
- A blessed base. One family of
devcontainer.jsonfiles the platform team maintains. Engineers extend; they do not invent from scratch. - Feature pins.
@1onfeaturesis a moving target; pin the exact version (@sha256:...) if reproducibility matters. - A tested DevPod provider config the platform publishes. Engineers
devpod provider add <url>and get the right EC2 instance types, VPC, and IAM roles. - A convention for the secret-passthrough mounts (
~/.aws,~/.databricks,~/.dbt) so every devcontainer in the org authenticates the same way. - A nightly job that rebuilds the base images so security patches land without every engineer noticing.
Important
The devcontainer must reflect production — same Python version, same JDK version, same CLI versions. A devcontainer where dbt 1.9 works locally and dbt 1.10 runs in CI is a devcontainer that lies.
Troubleshooting
Container build fails with "permission denied" on mounted files
The UID inside the container does not match the UID on the host. Add "updateRemoteUserUID": true to the devcontainer JSON, or map UIDs explicitly with "containerUser": "1000".
postCreateCommand runs every time I restart
It should run only on first create. If it is running on every restart, you probably put the command in postStartCommand by mistake. Check the devcontainer JSON.
Extensions listed in the JSON do not install
The container has to reach the Marketplace (or Open VSX, depending on your fork). Check outbound connectivity from the container. Corporate proxies often block the Marketplace; you need http.proxy in user settings or a VSIX sideload strategy.
DevPod fails to provision the VM
Usually an AWS quota or an IAM-permission issue. Run devpod up --debug <repo> and read the provider-specific log. Platform team owns the provider config; file an issue.
See also
- Workspace standards — what a compliant
.devcontainer/must satisfy. - The extension ecosystem — reproducibility via Extension Pack + devcontainer.
- The inner loop — what the environment supports once it is running.