# Pre-Building Standard Devcontainers with GitHub CI

By [Kyle Downey](https://paragraph.com/@kyle-downey) · 2025-05-04

---

To support [Left-of-Launch](https://mirror.xyz/0x46c5bBA2274211f81bC810bc227810Ac014d0BA6/SZ5f-VKdMcwWxE_PiFTQcFF-qx9c3rp-vG-b71V6uhc) quality checks and ensure a consistent development environment straight out of GitHub, I make extensive use of [VS Code’](https://code.visualstudio.com/)s [devcontainers](https://containers.dev). 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](https://devcontainer.community/20250303-prebuild-devcontainer/) 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](https://github.com/devcontainers/ci/tree/main) 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](https://github.com/devcontainers/action) and the [devcontainers/ci](https://github.com/devcontainers/ci/tree/main) 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/c`i to push an image as a side effect (via `cacheTo`, a trick I found [here](https://github.com/bascodes/prebuild-devcontainer-gha/actions/runs/13629113729/workflow)), I found problems with the manifest it generates, and in [this issue](https://github.com/devcontainers/ci/issues/224) 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](https://github.com/orgs/community/discussions/148648), 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:

*   [ghcr.io/lot49-cybernetics/lot49-base](https://github.com/lot49-cybernetics/devcontainers/pkgs/container/lot49-base): base image with pre-commit and other quality checks and security scans
    
*   [ghcr.io/lot49-cybernetics/lot49-base-deploy](https://github.com/lot49-cybernetics/devcontainers/pkgs/container/lot49-base-deploy): additional features for continuous deployment with Docker and Kubernetes, including local deploys
    
*   [ghcr.io/lot49-cybernetics/lot49-base-cpp](https://github.com/lot49-cybernetics/devcontainers/pkgs/container/lot49-base-cpp): a standard Modern C++ development container with a full C/C++ toolchain
    
*   [ghcr.io/lot49-cybernetics/lot49-base-polyglot](https://github.com/lot49-cybernetics/devcontainers/pkgs/container/lot49-base-polyglot): a multi-language development container supporting C/C++, Go, Python, Rust, TypeScript and Zig, as well some cross-language tools like Protobuf & WASM, and LaTeX for documentation
    

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](https://github.com/lot49-cybernetics/devcontainers/tree/main/images) for the source definitions for what’s included, and the [.github/workflows](https://github.com/lot49-cybernetics/devcontainers/tree/main/.github/workflows) folder to see the full source of the CI jobs.

---

*Originally published on [Kyle Downey](https://paragraph.com/@kyle-downey/pre-building-standard-devcontainers-with-github-ci)*
