A programmatic Go runtime for Dev Containers.
Embed the full devcontainer lifecycle — resolve, build, up, exec,
lifecycle phases, down — into your Go application without shelling out
to the Node @devcontainers/cli.
The reference @devcontainers/cli
is a Node binary. Embedding it in a Go service means a Node runtime
dependency, opaque failure modes (success exit codes with
outcome:error JSON on stdout), and CLI-flag-shaped APIs for every
interaction. This library is a clean Go implementation of the spec's
embedding-relevant subset, designed to be a drop-in replacement for
shelling out.
Alpha. API is stable enough for early integration but may change
between minor versions until v1.0.0. The events channel surface is
explicitly experimental.
The container backend is pluggable. Pick one at engine construction time:
runtime/docker— Docker Engine overmoby/moby/client. Default choice; requires a reachable Docker daemon socket.runtime/applecontainer— Apple'scontainerruntime on darwin/arm64 (macOS 15+). Talks to Apple's apiserver through an embedded Swift bridge (libACBridge.dylib, dlopen'd at runtime). Lets you run devcontainers on Apple Silicon without Docker Desktop.
Both backends implement the same runtime.Runtime interface — the
engine, feature pipeline, lifecycle, and compose paths don't care which
one you wire in.
Things specific to runtime/applecontainer that don't apply to the
Docker backend. None of these are bugs in this library; they reflect
the current state of Apple's container runtime (0.12.x).
- Daemon + builder lifecycle is manual. Run
container system startonce per boot;container builder startonce per machine before anybuild-source devcontainer orfeaturesinstall. Engine surfaces a typedruntime.BuilderUnavailableErrorwith a remediation hint when the builder is down. - Image-store credentials are separate from Docker's. Pulls from
private registries require
container registry login <host>before Up;~/.docker/config.jsonis not consulted. - Multi-arch base images. Apple's BuildKit shim ships amd64-only,
so the builder VM always runs amd64 (Rosetta on Apple Silicon).
Output images target the host platform (
arm64) by default — feature builds produce native arm64 images that run without Rosetta. If yourFROMbase image is amd64-only, the resulting image is also amd64 and the runtime container needs Rosetta-for-Linux to boot. - No amd64 default kernel. Running amd64 containers requires
installing the amd64 kernel explicitly:
container system kernel set --tar <url-to-amd64-tarball> --arch amd64. Don't use--forcewithout--arch; it can overwrite the arm64 default registration. - Port forwarding (
forwardPorts/ composeports:) is parsed but not actuated. Apple's networking model differs from Docker's host-port-publish; on this backend ports are silently dropped today. - Compose feature gates that depend on upstream apple/container
fixes are refused at Plan-validate time with typed errors:
service_healthy/service_completed_successfully(no healthcheck surface yet — apple/container #1502, #1501), namespace sharing modes (network_mode: service:<x>,pid:,ipc:— architectural, VM-per-container), shared named volumes (ext4 single-mount — apple/container #889).restart:policies are silently ignored with a one-shot warning (apple/container #286). - Service-name DNS is not native on the project network
(apple/container #856). The compose orchestrator patches
/etc/hostsin each running container after each level sodepends_on-declared peers resolve; intra-level peers without an explicit edge can lose a first lookup. Documented limitation.
Status of each Dev Containers spec
field/behavior the library covers. Legend: ✅ acted on ·
Sources
| Field | Status | Notes |
|---|---|---|
image |
✅ | Pull, run, exec |
build (dockerfile, context, args, target, cacheFrom) |
✅ | User Dockerfile + features layered atop |
dockerComposeFile, service, runServices |
✅ | compose-go parse + either shell-out to docker compose (default) or an in-process orchestrator (EngineOptions.ComposeBackend = ComposeBackendNative). The native orchestrator drives any Runtime implementing the compose primitives — works against both runtime/docker and runtime/applecontainer. |
Container config
| Field | Status | Notes |
|---|---|---|
workspaceFolder, workspaceMount |
✅ | |
mounts (bind / volume / tmpfs) |
✅ | |
containerEnv, remoteEnv |
✅ | |
containerUser, remoteUser |
✅ | |
updateRemoteUserUID |
✅ | Portable shell (Debian, Alpine/BusyBox) |
userEnvProbe (all four modes) |
✅ | |
runArgs, init, privileged, capAdd, securityOpt, overrideCommand |
✅ | |
shutdownAction |
✅ | Honored via Engine.Shutdown (none / stopContainer / stopCompose) |
Features
| Field | Status | Notes |
|---|---|---|
features (OCI / HTTPS / local) |
✅ | DAG ordering, options validation, content-addressed cache |
dependsOn / installsAfter / overrideFeatureInstallOrder |
✅ | |
| Pre-baked-image short-circuit | ✅ | devcontainer.metadata label read on Up |
devcontainer-lock.json |
❌ | #26 |
Lifecycle
| Phase | Status | Notes |
|---|---|---|
initializeCommand (host) |
✅ | Opt-in via UpOptions.RunInitializeCommand; requires EngineOptions.HostExecutor |
onCreateCommand, updateContentCommand, postCreateCommand |
✅ | Run-once idempotency markers |
postStartCommand, postAttachCommand |
✅ | Run-every-start / run-every-Up |
waitFor |
✅ | |
| Parallel command form (object → named commands run concurrently) | ✅ | |
secretsCommand |
✅ | Opt-in via UpOptions.RunSecretsCommand; requires EngineOptions.HostExecutor |
Substitution
| Variable | Status | Notes |
|---|---|---|
${localWorkspaceFolder[Basename]}, ${containerWorkspaceFolder[Basename]} |
✅ | |
${localEnv:VAR[:default]}, ${devcontainerId} |
✅ | |
${containerEnv:VAR[:default]} |
✅ | Two-pass: host context pre-create, re-applied per Exec via the workspace substituter |
Ports
| Field | Status | Notes |
|---|---|---|
forwardPorts |
Not actuated; #7 | |
portsAttributes, otherPortsAttributes |
Surfaced on ResolvedConfig; not enforced |
|
appPort (deprecated) |
✅ translated | Folded into forwardPorts (skipping container ports already declared); deprecation warning still emitted |
Other
| Behavior | Status | Notes |
|---|---|---|
customizations.<tool> pass-through |
✅ | map[string]json.RawMessage for callers |
hostRequirements |
Surfaced; not enforced | |
devcontainer.metadata label round-trip |
✅ | Written on build, read + merged on next Up |
| Unknown-field warnings (top-level + nested) | ✅ | Includes build, hostRequirements, gpu sub-objects |
| GPG / SSH agent forwarding | ❌ | #28 |
Out of scope (➖): Templates spec, dotfiles repos, IDE injection hooks, Kubernetes / podman drivers.
go get github.com/crunchloop/devcontainerRequires:
- Go 1.25+
- A container backend, one of:
- Docker: daemon socket reachable; Docker Compose v2 plugin
only when running
dockerComposeFileprojects under the default shellout backend (skip the plugin if you opt intoComposeBackendNative). - Apple
container: macOS 15+ on Apple Silicon,container system startalready up. Swift toolchain only if you're building the bridge from source — releases embed the pre-built dylib.
- Docker: daemon socket reachable; Docker Compose v2 plugin
only when running
package main
import (
"context"
"fmt"
"log"
devcontainer "github.com/crunchloop/devcontainer"
"github.com/crunchloop/devcontainer/runtime/docker"
)
func main() {
ctx := context.Background()
rt, err := docker.New(ctx, docker.Options{})
if err != nil {
log.Fatalf("docker: %v", err)
}
defer rt.Close()
eng, err := devcontainer.New(devcontainer.EngineOptions{Runtime: rt})
if err != nil {
log.Fatalf("engine: %v", err)
}
ws, err := eng.Up(ctx, devcontainer.UpOptions{
LocalWorkspaceFolder: "/path/to/your/project",
})
if err != nil {
log.Fatalf("up: %v", err)
}
defer eng.Down(ctx, ws, devcontainer.DownOptions{Remove: true})
res, err := eng.Exec(ctx, ws, devcontainer.ExecOptions{
Cmd: []string{"sh", "-c", "echo $USER"},
})
if err != nil {
log.Fatal(err)
}
fmt.Println("user:", res.Stdout)
}To target Apple's container runtime instead of Docker, swap the
backend import — the rest of the engine code is unchanged:
import "github.com/crunchloop/devcontainer/runtime/applecontainer"
rt, err := applecontainer.New(ctx, applecontainer.Options{})
// ... pass to devcontainer.New the same wayRunnable end-to-end examples in examples/:
image-source/— minimal image-only devcontainerwith-features/— image + a local feature withcontainerEnvcompose/— multi-servicedockerComposeFile
The main entry points live in the root package:
type Engine struct { /* ... */ }
func New(opts EngineOptions) (*Engine, error)
func Resolve(ctx context.Context, opts ResolveOptions) (*ResolvedConfig, error)
func (*Engine) Up(ctx, UpOptions) (*Workspace, error)
func (*Engine) Attach(ctx, WorkspaceID) (*Workspace, error)
func (*Engine) Exec(ctx, *Workspace, ExecOptions) (ExecResult, error)
func (*Engine) ExecByID(ctx, WorkspaceID, ExecOptions) (ExecResult, error)
func (*Engine) RunLifecycle(ctx, *Workspace, LifecyclePhase) error
func (*Engine) Down(ctx, *Workspace, DownOptions) errorSub-packages:
config— devcontainer.json parsing, merging, host-context substitutionruntime— container backend abstraction (Runtime,ComposeRuntime, capabilities, network/volume/list primitives)runtime/docker— Docker Engine API implementation (usesmoby/moby/client)runtime/applecontainer— Applecontainerimplementation; darwin/arm64 only, cgo-linked Swift bridgefeature— feature resolution (OCI / HTTPS / local), DAG ordering, dockerfile generationcompose—dockerComposeFileparsing viacompose-spec/compose-go, plus a runtime-agnostic in-process orchestrator (Orchestrator,Plan, topological + health gating) used whenComposeBackendNativeis selected
make test # unit tests
make test-integration # integration tests against real Docker
make lint # golangci-lintThe integration suite (build tag integration) exercises real Docker:
pulls public images from GHCR, builds Dockerfiles, runs feature install
scripts, drives docker compose up/down. Skipped automatically if a
Docker daemon isn't reachable.
Apple-container integration tests are tagged
integration && darwin && arm64 and run against a live container
apiserver — skipped when the daemon isn't running. CI runs both the
Linux + Docker suite and a macos-26 job that builds the Swift
bridge and runs the applecontainer unit tests.
See CONTRIBUTING.md. Bug reports welcome via GitHub issues.