A pre-built VS Code dev container for DevOps and Infrastructure-as-Code work: Terraform, Terragrunt, Azure CLI, Ansible, Kubernetes, Helm, PowerShell and .NET on Ubuntu 24.04. Published multi-architecture to the GitHub Container Registry with an SBOM and SLSA build provenance, so there is nothing to build before you start.
It is also a multi-root devcontainer: one container, several Git repositories open in a single VS Code window. A DevOps change is rarely confined to one repository - the Terraform module, the environment that consumes it, the Ansible role and the pipeline that ships it tend to move together. Instead of four windows running four containers, you get one Source Control panel listing every repository's pending changes, one search across all of them, and one toolchain to rebuild. See Multi-root workspaces.
docker pull ghcr.io/dbhq-uk/devcontainer-devops:latestThis devcontainer includes pre-configured tools for:
- Multi-root workspaces: the
wscommand clones repositories into a persistent volume and opens them all in one container - Infrastructure as Code: Terraform, Terragrunt, tflint, tf-summarize, checkov
- Cloud Management: Azure CLI (az), AzCopy
- Container Operations: Docker Engine, Helm, kubectl, kubelogin
- Configuration Management: Ansible with 9 popular collections
- Scripting & Automation: PowerShell 7 with modules, Python with DevOps tools, uv, Node.js, ZSH with Oh My Zsh
- .NET Development: .NET 10 SDK (LTS) with C# and C# Dev Kit extensions
- AI Tooling: Claude Code, Codex, cswap (Claude Code account switcher)
- Security: git-crypt, gitleaks, checkov, optional custom CA trust chain
- Development Utilities: Custom bash/zsh aliases, shell completions, pre-commit
- Data Processing: jq, yq
Most dev containers assume one repository per container. This one does not.
/workspace is a named Docker volume rather than a bind mount of a single
folder, so every repository cloned under it persists across rebuilds and sits
as a sub-folder of the workspace file. That second part is what makes multi-root
possible: VS Code will only open a multi-root workspace in a container when the
workspace "references relative paths to sub-folders of the folder the
.code-workspace file is in (or the folder itself)". Parent-relative paths such
as ../other-repo will not open, which is why the usual "sibling folders on the
host" layout fails.
| Command | What it does |
|---|---|
ws init |
Create /workspace/devops.code-workspace, seeded with any repositories already on the volume. Runs automatically when the container is created |
ws add <git-url> [name] |
Clone a repository into /workspace/<name> and add it as a root |
ws add <name> |
Add a repository already sitting on the volume |
ws rm <name> |
Drop a root from the workspace. The clone stays on disk |
ws list |
Show the current roots |
ws add https://github.com/acme/platform-terraform.git
ws add https://github.com/acme/platform-ansible.git
ws add git@github.com:acme/platform-pipelines.git
ws listThe workspace file lives on the volume, inside the container, so open it from a container window rather than from the host:
- File > Open Workspace from File⦠and pick
/workspace/devops.code-workspace, or - run Dev Containers: Open Workspace in Container from the host if you keep a
copy of the workspace file alongside your
.devcontainer
VS Code reloads into the multi-root view. Adding a root later needs a reload to show up.
Settings in devcontainer.json apply to the whole window. Anything that should
differ per repository - a two-space tab in the YAML repo, four in the .NET one -
belongs in the folders entries of the workspace file instead, which ws
leaves alone for you to edit.
Every root shares the one container. VS Code cannot run a container per folder in a single window, and that remains an open feature request upstream. This suits a team standardised on one toolchain, which is the normal DevOps case. It does not suit polyglot repositories that each need a different runtime version.
| Tool | Purpose |
|---|---|
ws |
Manage the roots of the multi-root VS Code workspace |
| Terraform | Infrastructure provisioning |
| Terragrunt | Terraform wrapper for DRY configurations |
| tflint | Terraform linting |
| tf-summarize | Human-readable Terraform plan summaries |
| checkov | IaC security and compliance scanning |
| Azure CLI | Azure cloud management |
| AzCopy | Bulk transfer to/from Azure Storage |
| Docker | Container runtime and management |
| Helm | Kubernetes package manager |
| kubectl | Kubernetes cluster management |
| kubelogin | Azure AD authentication for kubectl |
| Ansible | Configuration management and automation |
| PowerShell | Cross-platform automation and scripting |
| Python | Scripting with DevOps-focused packages |
| uv | Fast Python package/project manager (uv, uvx) |
| Node.js | JavaScript runtime and npm tooling |
| .NET SDK | C# / .NET 10 application development |
| Claude Code | Anthropic coding agent (self-updating, user-tree install) |
| Codex | OpenAI coding agent (run codex-init to configure) |
| cswap | Switch between Claude Code accounts (claude-swap) |
| git-crypt | Transparent encryption of files in git |
| gitleaks | Secret scanning |
| pre-commit | Git hook framework |
| jq / yq | JSON and YAML processing |
jq,zshand the other base utilities come from the apt package list and the upstream devcontainer base image rather than a dedicatedinstall-*.shscript β which is why they have no entry in theinstall/tree above.
uvis available for interactive use, but no tool in this image is installed through it β the Python-based tools (checkov, claude-swap, ansible) still use system-widepip.
devcontainer-devops/
βββ .github/
β βββ workflows/
β βββ build.yml # Reusable build: lint, build, test, scan, publish
β βββ ci.yml # Pull requests and pushes to main
β βββ release.yml # Weekly CalVer release
βββ .devcontainer/
β βββ Dockerfile # Multi-stage container build
β βββ devcontainer.json # VS Code devcontainer configuration
β βββ files/
β βββ install/ # Installation scripts (each uses /tmp/install-<tool>)
β β βββ _arch.sh # Sourced helper: architecture detection
β β βββ install-ansible.sh
β β βββ install-azcopy.sh
β β βββ install-azure-cli.sh
β β βββ install-checkov.sh
β β βββ install-claude-code.sh
β β βββ install-codex.sh
β β βββ install-cswap.sh
β β βββ install-docker.sh
β β βββ install-dotnet.sh
β β βββ install-git-crypt.sh
β β βββ install-gitleaks.sh
β β βββ install-helm.sh
β β βββ install-kubectl.sh
β β βββ install-kubelogin.sh
β β βββ install-node.sh
β β βββ install-powershell.sh
β β βββ install-pre-commit.sh
β β βββ install-python-tools.sh
β β βββ install-terraform.sh
β β βββ install-terragrunt.sh
β β βββ install-tf-summarize.sh
β β βββ install-tflint.sh
β β βββ install-uv.sh
β β βββ install-yq.sh
β βββ certs/ # Drop-in dir for extra CA certificates
β β βββ README.md # How to add your own CA chain
β βββ codex/ # Codex bootstrap (rendered by `codex-init`)
β β βββ codex-init
β β βββ config.toml.tmpl
β βββ home/ # Home directory files
β β βββ .bash_aliases # Convenience aliases
β β βββ .environment # Shell-aware environment config
β β βββ .zshrc # ZSH configuration
β β βββ .claude/ # Claude Code defaults
β β βββ .config/ # PowerShell profile and theme
β βββ workspace/ # Multi-root workspace tooling
β β βββ ws # Manages roots in devops.code-workspace
β βββ entrypoint.sh # Container entrypoint for home dir init
βββ tests/
β βββ integration-test.sh # Integration tests
β βββ run-all-tests.sh # Test runner
β βββ validate-tools.sh # Tool validation
βββ scripts/
β βββ check-latest-versions.sh
βββ .hadolint.yaml # Dockerfile lint rules, shared by CI and pre-commit
βββ ARCHITECTURE.md # System architecture documentation
βββ CHANGELOG.md # Version history
βββ CONTRIBUTING.md # Contribution guidelines
βββ QUICKSTART.md # Quick start guide
βββ README.md # This file
βββ SECURITY.md # Security policies
βββ VERSION_MANAGEMENT.md # Version management guide
-
Clone the repository:
git clone https://github.com/dbhq-uk/devcontainer-devops.git cd devcontainer-devops -
Open in VS Code:
code . -
Reopen in Container:
- Press
F1orCtrl+Shift+P - Select
Dev Containers: Reopen in Container - The pre-built image is pulled from GHCR, so there is no wait for a build
- Press
-
Start developing! The container will be ready with all tools pre-installed.
You do not need this repository to use the container. Point any
devcontainer.json at the published image, or pull it yourself:
docker pull ghcr.io/dbhq-uk/devcontainer-devops:latest| Tag | What it is |
|---|---|
latest |
The most recent weekly release |
2026.08.09 |
A specific weekly release |
2026.08 |
The most recent release in that month |
main |
Head of the default branch, rebuilt on every push |
sha-abc1234 |
One specific commit |
Images are published for linux/amd64 and linux/arm64; Docker picks the
right one automatically.
The devcontainer uses Docker volumes for persistent storage:
- Workspace Volume:
dev-workspace-<user>mounted at/workspace - Home Volume:
dev-home-<user>mounted at/home/vscode - Bind Mount: The local workspace folder mounted at
/workspace/devcontainer - Workspace File:
/workspace/devops.code-workspace, created byws init - Permissions: Automatically configured via
postCreateCommand - Home Init: Entrypoint script copies default configs on first run
This ensures your work and settings persist across container rebuilds.
Both volumes are per-user (suffixed with $USER), so several checkouts can run
side by side without sharing state. Note that /home/vscode is only seeded from
the image's /tmp-home template on first start β tools installed into the
home tree do not refresh on rebuild for an existing volume, which is why most
tooling installs system-wide.
If your environment terminates TLS with a private CA, drop the PEM-encoded chain
into .devcontainer/files/certs/ as a *.crt file before building. The
Dockerfile copies the directory to /usr/local/share/ca-certificates/extra/
and runs update-ca-certificates, merging it into the system bundle at
/etc/ssl/certs/ca-certificates.crt.
REQUESTS_CA_BUNDLE, SSL_CERT_FILE and NODE_EXTRA_CA_CERTS all point at that
bundle, which covers Python (az, ansible, checkov, β¦), curl, and Node.js β
Node ignores the system store, so it has to be told explicitly.
Certificates are git-ignored (*.crt, *.pem), so nothing is committed by
accident. Adding none is fine: the build succeeds and the container trusts the
public roots from the base image. See
.devcontainer/files/certs/README.md.
GitHub Actions builds, tests and publishes the image to the GitHub Container
Registry. There is nothing to configure - it runs on the repository's own
GITHUB_TOKEN, with no secrets and no external registry account.
| Workflow | Trigger | What it does |
|---|---|---|
ci.yml |
Pull requests | Lints, builds both architectures, runs the test suite. Publishes nothing |
ci.yml |
Push to main |
The same, then publishes :main and :sha-<short> |
release.yml |
Sundays 03:00 UTC, or manually | A --no-cache rebuild, published as a dated release and :latest |
Both call build.yml, which holds the actual
build so the two entry points cannot drift apart.
Each architecture builds on its own native runner - ubuntu-24.04 and
ubuntu-24.04-arm - rather than under QEMU emulation, which would take hours
for an image this size. Each runner builds its platform, loads it locally, runs
tests/run-all-tests.sh against the real image, and
only then pushes by digest. A final job merges the digests into one
multi-architecture manifest.
Releases are calendar-versioned: v2026.08.09 is the image as it was built on
that date. Most entries in versions.json are "install latest", so a tag is a
point-in-time snapshot rather than a reproducible build - rebuilding the same
tag a week later would produce a different image. If you need one exact
image, pin the digest, which every release records.
Every published image carries an SBOM and SLSA build provenance, generated by BuildKit and signed with a short-lived Sigstore certificate. Verify that an image really came from this repository:
gh attestation verify oci://ghcr.io/dbhq-uk/devcontainer-devops:latest \
-R dbhq-uk/devcontainer-devopsRead the SBOM out of the image:
docker buildx imagetools inspect ghcr.io/dbhq-uk/devcontainer-devops:latest \
--format '{{ json .SBOM }}'Trivy scans each build for HIGH and CRITICAL vulnerabilities and reports them to the repository's Security tab. Scans report, they do not block: an image bundling the Azure CLI, Ansible and a .NET SDK always carries some upstream findings, and blocking on those would stop the weekly rebuild and leave the published image staler than the CVEs it was avoiding.
-
Create an installation script in
.devcontainer/files/install/. It should take the desired version as$1, resolve the latest when that argument is empty, and verify the install before exiting:.devcontainer/files/install/install-your-tool.sh
The whole directory is copied and
chmod +x'd in one step, so noCOPYline is needed per script.The image is built for
amd64andarm64, so never hardcode an architecture. Source the shared helper and use the spelling your upstream uses:. "$(dirname "$0")/_arch.sh" # ARCH_DEB amd64 / arm64 Debian and Go convention, most releases # ARCH_X64 x64 / arm64 e.g. gitleaks # ARCH_GNU x86_64 / aarch64 Rust target triples, e.g. uv
If the tool has no
arm64Linux build, say so in a comment and skip it on that architecture rather than failing the build. -
Add an
ARG YOUR_TOOL_VERSION=to both blocks at the top of theDockerfile(before and after theFROM), then invoke the script:RUN /tmp/install/install-your-tool.sh ${YOUR_TOOL_VERSION}Place the
RUNas late in the file as the dependencies allow β a version bump invalidates every layer after it. -
Record the version in
versions.json, add avalidate_toolline totests/validate-tools.sh, and note it inCHANGELOG.md. -
Rebuild the container
versions.json is the documented source of truth for tool versions; an empty
string means "install latest". See VERSION_MANAGEMENT.md
for the per-tool version sources.
The Dockerfile ARGs default to empty (latest). .devcontainer/devcontainer.json
builds from the local Dockerfile by default β to pin, add the versions to its
build.args block:
"args": {
"UBUNTU_VERSION": "24.04",
"TERRAFORM_VERSION": "1.13.5",
"POWERSHELL_VERSION": "7.5.4"
}CI does not pass these build args, so published images install the latest of everything left unpinned in the
Dockerfile. That is deliberate - see Versioning - and it is why a release tag is a snapshot rather than a reproducible build.
ws add https://github.com/acme/platform-terraform.git
ws list
ws rm platform-terraformterraform init
terraform plan
terraform applyaz login
az account list
az group create --name myResourceGroup --location eastusdocker ps
docker build -t myimage .
docker run myimagekubectl get pods
helm install myrelease mychart/claude # start Claude Code
codex-init # one-off: configure Codex endpoint and deployment
cswap list # list managed Claude Code accounts
cswap status # show the account currently in use
cswap add # register the account you are signed in as
cswap switch # rotate to the next account- Create a feature branch
- Make your changes
- Test in the devcontainer
- Submit a pull request
MIT - see LICENSE.
- Ensure Docker Desktop is running
- Check Docker has sufficient resources (CPU/Memory)
- Try rebuilding without cache:
Dev Containers: Rebuild Container
- The
postCreateCommandshould handle this automatically - Manually run:
sudo chown -R vscode:vscode /workspace
- Verify the installation script exists in
.devcontainer/files/install/ - Check the
Dockerfilehas aRUN /tmp/install/install-<tool>.shstep - Confirm it appears in
tests/validate-tools.sh, then run that script - Rebuild the container
ws listshows the roots actually recorded in/workspace/devops.code-workspace- Adding a root needs a window reload before VS Code shows it
- A repository has to sit directly under
/workspaceto be a legal root - nested paths and../paths will not open - If the workspace file was never created, run
ws init
/home/vscode is a persistent per-user volume, seeded from the image only on
first start. Anything installed into the home tree (Claude Code, for example)
will not refresh for an existing volume. Remove the dev-home-<user> volume to
re-seed, or update the tool in place.
Open an issue. For
anything security-related, follow SECURITY.md instead of
opening a public issue.