Publishing a DevContainer Feature to GHCR
A custom Feature is only reusable across repos once it's published. This page packages a Feature and pushes it to GitHub Container Registry (GHCR) with the Features CLI, so other projects reference it by OCI path and pin its version like any dependency.
This matters because a Feature living in a single repository's .devcontainer folder cannot be shared: every project that wants the same installer has to copy install.sh and devcontainer-feature.json by hand, and those copies drift the moment one team patches a bug the others never see. Publishing to GHCR breaks that pattern. The Feature becomes an OCI artifact addressed by a path like ghcr.io/acme/mytool, and consumers pull it exactly the way they pull the community Features from ghcr.io/devcontainers/features. You reach for this the moment a Feature is useful in more than one place — a company-standard language toolchain, an internal CLI, a set of pre-baked credentials helpers — because centralizing the source is the only way to fix it once and have every consumer inherit the fix on their next rebuild.
The mental model to hold is that a published Feature behaves like a versioned package, not like a script. The publishing action stamps each release with semantic version tags so that ghcr.io/acme/mytool:1 tracks the latest 1.x while mytool with an explicit version option locks to an exact build. That tag hierarchy is what lets consumers choose their own update cadence: some pin the major and accept minor bumps automatically, others freeze on an exact version and review every change. Treating the OCI reference as a dependency — something you resolve, pin, and audit — is the shift that turns a one-off installer into shared infrastructure the whole organization can rely on.
Prerequisites
You need a built Feature and GHCR publish access.
- A Feature with
install.shanddevcontainer-feature.json. - The DevContainer Features publishing action or CLI.
- GHCR write access for the namespace.
Each of these is easy to assemble but easy to misjudge. The Feature source has to already build and install correctly in isolation, because the publishing action does not run your install.sh — it only packages the directory and pushes the layers, so any bug in the installer surfaces later on a consumer's machine rather than in your publish job. The DevContainer Features publishing action (or the devcontainer features publish CLI subcommand) is what turns your directory into an OCI artifact; it reads devcontainer-feature.json for the Feature id and version and refuses to publish if the metadata is missing or malformed. GHCR write access means the workflow's token can push packages under your chosen namespace, which is a distinct grant from ordinary repository read or write.
The one detail people get wrong is the relationship between the Feature id in devcontainer-feature.json, the source directory name, and the final OCI path. The publishing action derives the package name from the directory under base-path-to-features, and it must match the id field exactly — a directory called mytool with an id of my-tool publishes to a path nobody expects, and consumers referencing ghcr.io/acme/mytool get a not-found error. Decide the canonical id first, name the directory after it, and set the id field to the same string before you ever run the publish job.
Step-by-Step Implementation
- Lay out the Feature in the standard structure.
src/mytool/devcontainer-feature.json
src/mytool/install.sh
This layout is not a convention you can bend — it is the contract the publishing tooling scans for. Everything under src/ is treated as a collection, and each immediate subdirectory (mytool here) is one publishable Feature. The devcontainer-feature.json next to install.sh supplies the id, version, options, and any installsAfter ordering, while install.sh is the script the runtime executes inside the build. Keeping both files at the top of the Feature directory — not nested a level deeper — is what lets the action discover the Feature at all; a stray extra folder between src/mytool/ and the two files makes the action skip it silently and publish nothing, which is the most common reason a "successful" workflow produces no package.
- Publish with the official action on release.
- uses: devcontainers/action@v1
with:
publish-features: true
base-path-to-features: ./src
The devcontainers/action@v1 step is the officially maintained publisher, and the two with: inputs tell it exactly what to do. publish-features: true switches the action into publish mode rather than test-only mode, and base-path-to-features: ./src points it at the collection root so it walks every subdirectory looking for Feature metadata. Running this on release — gated by a tag push or a release event rather than on every commit — is deliberate: each run pushes new version tags to GHCR, so triggering it on every branch push would flood the registry with half-baked versions and make it impossible for consumers to tell which tags represent real releases. The action also needs the workflow to grant packages: write; without that permission the push fails with a 403 even though the packaging step itself succeeded.
- Reference it by OCI path, pinned.
{ "features": { "ghcr.io/acme/mytool:1": { "version": "1.2.0" } }, "remoteUser": "vscode" }
There are two independent version signals in this line, and it helps to read them separately. The :1 suffix on the OCI path is the registry tag the resolver pulls — it follows the major line, so the consumer picks up 1.x patches as they ship. The "version": "1.2.0" inside the options object is a value passed to your Feature's own logic, typically the tool release the installer downloads. Keeping both explicit means a consumer's toolchain is reproducible: the Feature package and the software it installs are each anchored to a known value, and neither drifts to a surprise "latest" behind the team's back. The remoteUser key sits alongside the Feature to show that Features compose with the rest of the DevContainer configuration rather than replacing it.
- Verify a consumer build resolves it.
devcontainer read-configuration --workspace-folder .
devcontainer read-configuration is the cheapest way to confirm the reference resolves, because it performs the metadata resolution — fetching the Feature manifest from GHCR and merging it into the effective configuration — without paying for a full image build. If the OCI path is wrong, the namespace is private to a token the local CLI does not hold, or the tag does not exist, this command fails immediately with a resolution error, so you catch the mistake in seconds rather than after a multi-minute build. Run it from a clean checkout of a real consumer repo, not the Feature's own repo, so you exercise the same anonymous or token-scoped pull path a teammate's machine will use; a Feature that resolves for the author but 404s for everyone else is almost always a package that was left private in GHCR's package settings.
Common Pitfalls
Publishing issues are wrong layout, missing permissions, or unpinned consumption.
The permission failures deserve special attention because they masquerade as something else. A newly published package in GHCR defaults to private and inherits ownership from the workflow that created it, which means the token pushing the Feature owns it but the wider organization — and every anonymous consumer build — cannot read it. The symptom is a 403 or a denied on the push when the workflow lacks packages: write, or a not found on the pull when the package exists but its GHCR visibility was never flipped to public or linked to the source repository. The fix is two-sided: grant packages: write in the workflow so the push succeeds, then open the package's settings in GHCR and set its visibility and repository linkage so consumers can actually resolve it. Skipping the second half is why so many Features publish "successfully" yet fail for every teammate.
The other pitfall that bites later is silent unpinned consumption. When a consumer writes ghcr.io/acme/mytool with no tag and no version option, the resolver quietly takes latest, so their toolchain floats to whatever you published most recently. That looks convenient until a Feature release changes a default and a dozen repos rebuild differently on the same morning with no config change to blame. Publish with a disciplined semantic version scheme, document the major tag consumers should reference, and treat any bump they accept as a reviewed change rather than an automatic one — the same pin-and-review discipline you apply to base images applies to the Features layered on top of them.
| Symptom | Root Cause | Remediation |
|---|---|---|
| Publish fails | Wrong Feature layout | Use src/ |
| 403 pushing to GHCR | No package write permission | Grant packages: write to the workflow |
| Consumers get 'latest' silently | Referenced without a version | Pin ghcr.io/org/id to a version |
| Feature not found | Wrong OCI namespace | Match the namespace to your GHCR org |
Conclusion
Publishing a Feature to GHCR turns it into an OCI artifact any project references by path and pins by version — exactly like the official Features. Use the standard src/<id>/ layout and the publishing action, and pin it in consumers so updates are intentional.
The strategic payoff is that a published Feature becomes a single point of maintenance for a capability that used to be copy-pasted. Fix a bug in install.sh, cut a new patch release, and every consumer inherits it on their next rebuild by bumping the tag they already reference — no coordinated pull request across a dozen repositories, no drift between teams. That is the whole reason the DevContainer ecosystem distributes Features as OCI artifacts rather than shell snippets: the registry is the source of truth, and the version tags are the contract between author and consumer.
This ties directly into the broader pin-and-cache and reproducibility themes that run through dev container practice. A build that pins its base image, pins the Features layered on top, and pins the tools those Features install is a build that resolves to the same bytes tomorrow as it does today, on CI as on a laptop. Publishing to GHCR is the piece that makes the middle layer — your own Features — participate in that guarantee, because an OCI reference with an explicit version is something the resolver can cache, audit, and reproduce. Keep the id, directory name, and OCI path aligned, keep the package visibility correct, and keep consumers pinned, and the Feature you author once behaves like the dependable, versioned dependency the rest of the toolchain already treats it as.
FAQ
Why publish to GHCR instead of a local path?
A local path only works within one repo. Publishing to GHCR gives an OCI reference (ghcr.io/org/id) any project can consume and pin, so a Feature authored once is reused across every repo — the same distribution model as the official DevContainer Features. A local ./ reference also cannot be versioned, so there is no way to let one repo stay on an older revision while another moves forward; the published artifact solves both problems at once. Once it lives in GHCR, the Feature is discoverable, cacheable, and auditable the same way any other dependency is.
How do consumers pin my Feature?
They reference it by OCI path with a version, e.g. ghcr.io/acme/mytool:1 plus a version option, and treat bumps as reviewed changes. Pinning keeps a Feature update from silently changing their toolchain, matching base-image and Feature pinning discipline. The :1 tag tracks the major line so patches flow in automatically, while an exact tag like :1.2.0 freezes the package itself; teams choose which cadence fits their risk tolerance. Whichever they pick, the reference should live in version control so a rebuild months later resolves to the same Feature it did the day the config was written.
What permissions does the publish workflow need?
packages: write for the GHCR namespace, so the action can push the Feature package. Grant it in the workflow's permissions block and ensure the namespace matches your organization, then the publishing action pushes on release. Note that this permission only governs the push — after the first publish you usually also have to open the package in GHCR and set its visibility to public (or link it to the source repo) so consumers can pull it, since new packages default to private. The two settings are independent, and both must be correct for an end-to-end consumer build to resolve the Feature.
How do I version a new release of the Feature?
Bump the version field in devcontainer-feature.json following semantic versioning before you trigger the publish job. The action reads that field and pushes the corresponding tags — the exact version plus the rolling major and minor tags — so ghcr.io/acme/mytool:1 starts pointing at the new build automatically. Because that rolling tag is what most consumers reference, treat every version bump as a change that will reach them, and reserve major bumps for breaking changes to options or behavior.
Can I test the Feature before publishing it?
Yes, and you should. The devcontainers/action@v1 step also supports a test mode that builds a scenario against your Feature without pushing anything to GHCR, which is how you catch an install.sh failure before it becomes a published, broken release. Running the tests on pull requests and gating the publish job on a release event keeps unverified builds out of the registry while still letting you iterate freely on branches.
Why does my Feature resolve for me but 404 for teammates?
Almost always because the package is still private in GHCR. The author's local CLI carries a token that can read the private package, so read-configuration succeeds for them while an anonymous or differently-scoped pull from a teammate's machine or CI returns not-found. Open the package settings, set its visibility appropriately, and confirm the namespace in the OCI path matches the org that owns the package.
Related
- Up to Feature & Lifecycle Hook Sequencing — how Features are referenced and ordered.
- Writing a Custom DevContainer Feature — authoring the Feature you publish.
- Container Registry Best Practices for Dev Images — the registry hygiene Features share.