Pre-Building Standard Devcontainers with GitHub CI
May 4th, 2025

To support Left-of-Launch quality checks and ensure a consistent development environment straight out of GitHub, I make extensive use of VS Code’s devcontainers. The one problem is once you have built up a large set of tools, the time to rebuild the container gets to be quite long -- unnecessarily so, because the vast majority of the layers in the container never change. Ideally we want the majority of our core features to just be available as a pre-built image.

If you search for how to do this, though, you might find that the recommended way of doing this does not actually work, and if you dig a little deeper -- some links below -- you will find that it’s not currently how the maintainers of devcontainers/ci intend the action to be used. Maybe someone will contribute a dedicated GitHub Action to publish pre-built images, but until then the below technique will work.

Setting up CI

Create a file under .github/workflows/ci.yml with the following header:

---
name: devcontainer-ci

on:
    push:
        paths:
            - .github/workflows/ci.yml
            - images/**/*

permissions: read-all

This ensures that whenever you push an update either to the GitHub Action definition itself or to anything under images, it will run the jobs in this workflow. Updates to unrelated files like README.md won’t trigger a run.

The permissions settings allow this workflow to both read code and repo secrets.

Job essentials

The GitHub Action needs to both check out your code from GitHub and run everything under Ubuntu 24.04 (as of May 2025):

jobs:
    pre-build-base:
        runs-on: ubuntu-latest
        steps:
            - name: Checkout
              uses: actions/checkout@v3

Registry login

We are going to want to publish our pre-built images into a registry. GitHub Actions support both Docker Hub and GitHub’s own GHCR; we’ll use the latter for simplicity:

            - name: Login to GitHub Container Registry
              uses: docker/login-action@v2 
              with:
                registry: ghcr.io
                username: ${{ github.repository_owner }}
                password: ${{ secrets.GITHUB_TOKEN }}

Note we’re using injected variables for the repository owner and the GITHUB_TOKEN we need to authenticate to the registry. If we were setting up with Docker Hub, we’d need to set up secrets in the repository, then inject:

            - name: Login to Docker Hub
              uses: docker/login-action@v2
              with:
                  username: ${{ secrets.DOCKER_USERNAME }}
                  password: ${{ secrets.DOCKER_PASSWORD }}

Pre-building the image: single-platform

This is where we need to do some gymnatics. devcontainers/ci auto-installs devcontainers/cli for you, but I found calling it directly works much better. That means we need to set up NodeJS, and do an npm install:

            - name: Set up Node
              uses: actions/setup-node@v4
              with:
                node-version: 22
            - name: Install Devcontainer CLI
              run: npm install -g @devcontainers/cli

With that done, we can get to the heart of it, and directly involve the CLI:

            - name: Pre-build and publish lot49-base
              run: devcontainer build --workspace-folder images/lot49-base --image-name ghcr.io/lot49-cybernetics/lot49-base:noble --push

This specifies a folder in your GitHub repository which needs to have .devcontainer/devcontainer.json inside of it, in this case images/lot49-base. Simply giving it an image name with a tag and adding --push is enough to do a full build, manifest generation and publish to GHCR. This also gives us access to the full command line, so if there are further customizations (e.g. adding labels to the metadata) we can do it directly here.

GitHub Actions offers the devcontainers/action and the devcontainers/ci action, and you may be wondering why this post doesn’t recommend using either of them.

  1. At this time, devcontainers/action just supports publishing features and templates, not entire devcontainer images.

  2. While it is possible to prompt devcontainers/ci to push an image as a side effect (via cacheTo, a trick I found here), I found problems with the manifest it generates, and in this issue the maintainers indicate they don’t see building and publishing devcontainer images as their focus; the CI action is really meant for building devcontainers to use in a GitHub Action; that’s worthwhile because it ensure a common set of tools, but it’s not what we’re after here today.

Again, this may change in the future. GitHub has provided an amazing library of building blocks, and the flexibility to roll your own steps or publish your own actions into their marketplace where they do not meet your needs.

Multi-platform images: first try

Finally, since GitHub runners are on x86 but many developers nowadays work on ARM-based MacOS notebooks and desktops, let’s extend it to build cross-platform.

Multi-architecture builds requires installing the Docker buildx plugin plus QEMU:

            - name: Set up QEMU
              uses: docker/setup-qemu-action@v3
            - name: Setup Docker buildx for multi-architecture builds
              uses: docker/setup-buildx-action@v3
              with:
                  use: true

We then can add --platform to our build command to specify multiple targets:

            - name: Pre-build and publish lot49-base
              run: devcontainer build --platform linux/amd64,linux/arm64 --workspace-folder images/lot49-base --image-name ghcr.io/lot49-cybernetics/lot49-base:noble --push

Although GitHub is piloting native ARM64 runners, right now they only have x86. QEMU lets you emulate ARM64 on x86, but like many emulators, it is far slower than a native build; expect to wait a while if you’re including ARM64. I found it was at least 45 minutes per step; YMMV.

Standard images

I have open-sourced four different images based on this method:

Currently these are all derived from mcr.microsoft.com/devcontainers/base:noble, so they run under amd64 or aarch64 versions of Ubuntu 24.04 (“Noble Numbat”).

You can pick the base image which best meets your need, install extensions on top, and start developing!

See https://github.com/lot49-dev/devcontainers/images for the source definitions for what’s included, and the .github/workflows folder to see the full source of the CI jobs.

Subscribe to Kyle Downey
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.
More from Kyle Downey

Skeleton

Skeleton

Skeleton