devc

module
v1.3.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 31, 2026 License: Apache-2.0

README

devc - a minimal devcontainer runner

devc reads a devcontainer.json, brings the container(s) up with podman or docker, and gives you an SSH host an editor's Remote-SSH can connect to (VSCodium/VS Code). Think devpod, without the provider abstraction, features, or cloud. It is a standalone tool.

cd ~/src/myproject
devc up      # build/start, then write ~/.config/devc/ssh.config.d/<id>.config
             # now: ssh devc.myproject  (or point Remote-SSH at devc.myproject)
devc down    # remove the container(s)

Install

With Go (installs a binary named devc):

CGO_ENABLED=0 go install github.com/terrakuh/devc/cmd/devc@latest

CGO_ENABLED=0 is required: the binary injects itself into arbitrary container images, so it must be static (see How SSH works). Use @latest or pin a tag like @v1.0.0. The version is read from the build info, so a tagged install (or a clean release-tag build) reports that tag with no extra flags.

Or build from source:

CGO_ENABLED=0 go build -o devc ./cmd/devc

# smaller, reproducible binary:
CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o devc ./cmd/devc

The host binary and the container must share a CPU architecture (there is no cross-build); devc doctor reports a mismatch. Prebuilt static linux binaries for amd64 and arm64 are attached to each GitHub release.


How SSH works

There is no sshd in the image and no published port. The devc binary injects itself into the container as an SSH server and talks the SSH protocol over a podman exec pipe. You are already allowed to reach the container (you can run the container runtime), so no network auth is needed. The SSH crypto still runs because a real editor is a real SSH client, but the keys are generated per workspace, stay on your host, and never leave the machine.

ssh devc.myproject
  -> ProxyCommand: devc ssh --stdio --start --config <abs>/devcontainer.json --runtime /usr/bin/podman myproject
       -> podman exec -i <ctr> /.devc/agent __serve   <-- SSH -->  in-container agent
                                                                     - session (shell/exec/pty/sftp)
                                                                     - direct-tcpip  (ssh -L)
                                                                     - tcpip-forward (ssh -R)

Because the binary runs inside an arbitrary image, it must be built static (CGO_ENABLED=0) so it carries no dynamic libc dependency. See Install.


Commands

Command What it does
devc up [service...] build/start, inject the agent, run hooks, write ssh config. Flags: --recreate, --rebuild, --code (open editor once up), --editor <bin>, --skip-hooks, --rerun-hooks
devc down [service...] remove the container(s). --volumes (compose), --purge (also drop keys, ssh block, control dir), --auto (honor shutdownAction)
devc stop [service...] stop without removing
devc restart [--all] [service...] restart the main service (compose: just the attach service; --all restarts every service)
devc status [--json] this workspace: kind, runtime, state, ports, config drift
devc ps [--json] [service...] this workspace's containers, one row per compose service (declared-but-absent ones included)
devc list [--json] every devc workspace on the host (found by label)
devc logs [--follow] [service...] container / compose logs
devc exec [-T] [--service <s>] -- <cmd> run as the remote user in the workspace folder, with remoteEnv
devc code [--editor <bin>] open the workspace in VSCodium (preferred) or VS Code over Remote-SSH
devc ssh [<name>] spawn ssh devc.<name> (shares the ControlMaster)
devc ssh --stdio [--start] ProxyCommand transport (what the generated config runs)
devc ssh-config [--print] regenerate (or preview) the workspace's ssh config block
devc keys [--rotate] show or rotate the workspace's SSH keys
devc doctor [--json] preflight: runtime, container, tar/curl, libc, $HOME, disk, agent
devc config [--raw] print the resolved Spec (or the post-substitution raw doc)

Global flags: --path, -n/--name, --config, --runtime, --compose-cmd, --platform, --selinux, --userns, -q, --forward-agent, --sync-git-config (the last two override the matching customizations.devc.credentials keys).

-n/--name targets a workspace by its devc list name (or id) instead of a folder, so you can run e.g. devc code -n shop or devc stop -n api from anywhere. It resolves the workspace via container labels, so it works for any workspace that still has a container (running or stopped). Compose workspaces resolve too, but only once devc up has recorded them (see below).

Targeting single services (compose)

On a compose workspace, up, down, stop, restart, logs and ps take service names after their flags; with none they behave exactly as before and act on the whole workspace.

devc ps                    # what this workspace is made of, and what is up
devc restart db cache      # restart just these two services
devc up --rebuild db       # rebuild and recreate one service
devc stop db               # leave the rest of the project running
devc down db               # stop+remove db's container (compose rm), project stays
devc logs --follow db api  # follow two services
devc exec --service db -- psql -U dev   # a shell in a non-workspace service

devc ps is the per-workspace counterpart of devc list: it lists the project's containers by service, rounded out with the services the compose files declare but that were never created, and stars the one devc attaches to.

SERVICE     STATE        CONTAINER     IMAGE
cache       not created  -             -
db          exited       a1b2c3d4e5f6  postgres:16
workspace*  running      0f9e8d7c6b5a  fedora:44

* the workspace service devc attaches to

Because Go's flag parsing stops at the first positional argument, flags have to come before the service names: devc restart -n shop db, not devc restart db -n shop.

Naming services only changes which containers the verb touches, never the workspace's identity:

  • devc up <services> that leaves out the workspace's own service starts them and stops there - no hooks, no provisioning, no ssh config, since there is no workspace container in play.
  • devc down <services> runs compose rm --force --stop rather than down, so the project's network and named volumes survive for the services still up. --volumes and --purge are workspace-wide and are rejected with services.
  • devc exec --service <s> runs plainly in that container: remoteUser, the workspace folder and remoteEnv describe the workspace's container, not anyone else's.

Config support

Both forms of devcontainer.json are supported (JSONC: comments and trailing commas allowed).

  • Compose: dockerComposeFile (string or array, applied in order), service, runServices, workspaceFolder, forwardPorts.
  • Single container: image or build.{dockerfile,context,args,target,...}, workspaceMount, mounts, containerEnv, containerUser, runArgs, init, privileged, capAdd, securityOpt, appPort.

Shared by both: name, remoteUser, remoteEnv, overrideCommand, shutdownAction, userEnvProbe, waitFor, and all lifecycle hooks (initializeCommand on the host; onCreate/updateContent/postCreate once per container identity; postStart every start; postAttach). Variable substitution covers ${localWorkspaceFolder}, ${localEnv:VAR:default}, ${containerWorkspaceFolder}, ${devcontainerId}, and deferred ${containerEnv:VAR}.

devc reads its own options from customizations.devc (see Credential forwarding); other customizations entries (e.g. vscode) are ignored.

Not supported (by design): features, hostRequirements, and updateRemoteUserUID are rejected with a clear error. portsAttributes is parsed and ignored. On the compose path devc never generates an override file, so single-container-only keys used alongside dockerComposeFile are rejected instead of silently dropped; the compose file itself must keep the service alive (command: sleep infinity).

forwardPorts become LocalForward lines in the ssh config, so the ports work the moment you connect, with no publishing and no host-port collisions.


Credential forwarding

devc can make host credentials available in the container without copying them. Both options are opt-in under customizations.devc.credentials:

{
  "image": "fedora:44",
  "customizations": {
    "devc": {
      "credentials": {
        "forwardAgent": true, // ssh-agent forwarding for git-over-ssh
        "syncGitConfig": true, // copy host git identity into the container
      },
    },
  },
}

Override either per-run without editing the file: devc up --forward-agent, devc up --sync-git-config=false.

  • forwardAgent turns on ssh-agent forwarding. The ssh config gets ForwardAgent yes and the injected agent exposes a proxy SSH_AUTH_SOCK inside the container. Signing requests are tunnelled back to your host agent, so the private key never enters the container and there is no ssh-agent (and no key) in the image. You need a running host agent with keys added (ssh-add -l should list them). No host agent means no SSH_AUTH_SOCK is set.
  • syncGitConfig copies a small allowlist of host git settings (user.name, user.email, user.signingkey, commit.gpgsign, tag.gpgsign, gpg.format, init.defaultBranch) into the container's ~/.config/git/config on devc up. That file sits below ~/.gitconfig in git's precedence, so the container can still override it. It is best-effort: no host git or config just skips.

The credentials block is the place for future forwarding (gpg, git credential helper, docker) to land.


Fedora / rootless podman notes

  • SELinux: bind mounts get :z automatically when selinuxenabled is true. Override with --selinux=auto|z|Z|none.
  • User mapping: devc does not default to --userns=keep-id. Under plain rootless podman, container UID 0 already maps to your host user, so files land as yours while root stays available for /.devc setup and the agent's privilege drop. Pass --userns=keep-id only for a fixed non-root container user.
  • The agent installs and runs as --user 0, then drops to the session user itself (the same model as sshd).

State and credentials

Per workspace, under ~/.local/share/devc/<id>/ (0700):

File Purpose
id_ed25519(.pub) client key; the public half is the container's authorized key
host_key(.pub) the agent's host key (the only private half copied into the container)
known_hosts pinned, so StrictHostKeyChecking yes is honest
state.json workspace name/folder, container identity, hooks, probed env, agent

devc list finds single-container workspaces by devc's own labels, and compose workspaces by their devc-<id> project label - compose creates those containers itself, so devc cannot label them, and their name and folder are read back from state.json instead. A compose workspace therefore shows a blank folder (and is not addressable by -n/--name) until the first devc up records it.

The SSH ControlMaster socket lives under /tmp/devc-<uid>/<id>/. This short path matters: the state dir is too deep for the ~104-char Unix socket limit, which would silently break connection multiplexing. Each workspace writes its own ~/.config/devc/ssh.config.d/<id>.config, and ~/.ssh/config gets one idempotent Include ~/.config/devc/ssh.config.d/*.config line (with a timestamped backup on first write) - so bringing up one workspace never overwrites another's Host block.

The workspace id is <slug>-<sha256(abs folder)[:8]>: stable across rebuilds and unique across two checkouts of the same repo.


Troubleshooting the editor connection

The generated ProxyCommand bakes in an absolute --config and --runtime so it resolves correctly whatever working directory or PATH the editor spawns it with (GUI editors give it neither of yours). If Remote-SSH fails with a generic "connection lost before handshake" / "premature close", the editor has thrown away the ProxyCommand's stderr, so devc logs every step of the --stdio transport to a file:

cat /tmp/devc-$(id -u)/ssh-proxy.log

That shows how far it got: which runtime was resolved, the exact podman exec line, and the exit error. Then devc doctor checks the in-container side (tar, curl/wget, libc, $HOME, disk, agent version) that the editor's server installer needs.

After upgrading devc, reconnect with a fresh container (devc up --recreate), and clear a stale mux socket with ssh -O exit devc.<name> if needed.


Testing

Everything except the container runtime is unit-tested with no daemon present: config parsing (jsonc, substitution, resolution), argv construction (runtime.FakeRunner), ssh config rendering, hooks, and the agent over a real x/crypto/ssh client (publickey accept/reject, exec exit codes, PTY, direct-tcpip, tcpip-forward, SFTP round-trip).

go test ./...                               # unit tests, no daemon needed
go test -tags devc_integration ./cmd/devc/  # end-to-end, needs podman

Verify the editor itself manually, once; it is not part of the automated suite.

Directories

Path Synopsis
Package agent implements the in-container SSH server that devc injects and runs over `podman exec -i ...
Package agent implements the in-container SSH server that devc injects and runs over `podman exec -i ...
cmd
devc command
Command devc is a minimal devcontainer runner: it reads a devcontainer.json (compose or single-container form, no features/customizations), brings the container(s) up with podman or docker-compose, and exposes an SSH endpoint a local editor (VSCodium Remote-SSH) can connect to.
Command devc is a minimal devcontainer runner: it reads a devcontainer.json (compose or single-container form, no features/customizations), brings the container(s) up with podman or docker-compose, and exposes an SSH endpoint a local editor (VSCodium Remote-SSH) can connect to.
Package config loads and resolves devcontainer.json into a semantic Spec.
Package config loads and resolves devcontainer.json into a semantic Spec.
Package container turns a resolved config.Spec into container-runtime operations: build, create, start, reuse, exec, and inspection.
Package container turns a resolved config.Spec into container-runtime operations: build, create, start, reuse, exec, and inspection.
Package hooks runs devcontainer lifecycle commands.
Package hooks runs devcontainer lifecycle commands.
Package jsonc converts JSONC (JSON with comments and trailing commas, the dialect VS Code uses for devcontainer.json) into plain JSON that encoding/json accepts.
Package jsonc converts JSONC (JSON with comments and trailing commas, the dialect VS Code uses for devcontainer.json) into plain JSON that encoding/json accepts.
Package keys manages the per-workspace SSH credentials devc uses to secure the agent transport.
Package keys manages the per-workspace SSH credentials devc uses to secure the agent transport.
Package runtime abstracts the container CLI (podman or docker) behind a small Runner interface.
Package runtime abstracts the container CLI (podman or docker) behind a small Runner interface.
Package sshconf generates the OpenSSH client configuration that points an editor (VSCodium Remote-SSH) or the ssh CLI at a devc workspace.
Package sshconf generates the OpenSSH client configuration that points an editor (VSCodium Remote-SSH) or the ssh CLI at a devc workspace.
Package state manages devc's per-workspace host-side directories and the state.json that records facts we must not keep inside the container: which container identity a workspace last used, which lifecycle hooks have run, the cached environment probe, and the injected agent version.
Package state manages devc's per-workspace host-side directories and the state.json that records facts we must not keep inside the container: which container identity a workspace last used, which lifecycle hooks have run, the cached environment probe, and the injected agent version.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL