
Platform Engineering
DevPod and Coder: Sovereign Dev Environments on Kubernetes
DevPod upstream stopped cutting stable releases in March 2025. Build the self-hosted inner loop on devcontainer.json so the runtime stays replaceable.
Self-hosting programmes work outward from production: clusters come home, then registries, then CI, then identity. The environment engineers type into gets exempted almost silently, because it feels like a personal tool. It is not. It holds a working checkout, live credentials, and the build cache of every service you ship.
The inner loop is the last surface you still rent
A hosted cloud development environment holds three things worth naming. The working copy: not the repository of record, but an unencrypted checkout on compute you do not operate. The credentials that make it useful — a git token, a registry pull secret, whatever cloud role it assumes. And the build cache, where your dependency graph and internal package names sit in the open.
The commercial shape matters as much as the technical one. GitHub Codespaces bills compute per machine-hour and storage per GB-month: at list prices, $0.18 per hour for a 2-core machine rising to $2.88 for 32-core, plus $0.07 per GB-month. A Codespace stops after a default 30 minutes of inactivity, configurable only between 5 and 240 minutes.
That meter is behavioural, not just financial: it teaches engineers to treat the workspace as disposable and the laptop as the real machine — the habit that undoes the control you were buying. Self-hosted, the environment stops being someone else's product surface and joins the platform you already run golden paths on.
devcontainer.json is the contract; the runtime is an implementation detail
The specification at containers.dev describes a development container as a definition that deterministically creates containers under the control of the user. That phrase is the whole argument for building on it: the definition is yours, the thing that reads it is interchangeable. Supporting tools look for .devcontainer/devcontainer.json, then .devcontainer.json, then .devcontainer/<folder>/devcontainer.json, in that precedence order.
The licensing underneath makes the contract durable rather than merely popular: the reference devcontainers/cli is MIT and the specification repository CC-BY-4.0, so neither can be withdrawn under you. The spec also fixes the lifecycle order — initializeCommand, onCreateCommand, updateContentCommand, postCreateCommand, postStartCommand, postAttachCommand — and if any script fails, the rest are skipped. That ordering makes a cold start reproducible across runtimes rather than dependent on one.
Write the definition so it survives the runtime disappearing: digest-pinned base, internal mirrors through containerEnv, setup split across the hooks in the order the spec fixes. One line below deserves a decision rather than a copy. GOSUMDB: off disables checksum-database verification for every module the workspace resolves — coherent where the internal mirror genuinely is the authority for what a version contains, a supply-chain control quietly removed anywhere else. Go's narrower lever is GONOSUMDB: scope the exemption to your own prefixes.
{
"name": "platform-api",
"image": "registry.internal.example.com/dev/base-jammy@sha256:9f2c0b1a7d3e4f5061728394a5b6c7d8e9f0a1b2c3d4e5f60718293a4b5c6d7e",
"features": {
"ghcr.io/devcontainers/features/go:1": { "version": "1.24" }
},
"remoteUser": "dev",
"containerEnv": {
"GOPROXY": "https://goproxy.internal.example.com",
"GOSUMDB": "off",
"npm_config_registry": "https://npm.internal.example.com/",
"PIP_INDEX_URL": "https://pypi.internal.example.com/simple"
},
"onCreateCommand": "sudo /usr/local/share/trust-internal-ca.sh",
"updateContentCommand": "go mod download",
"postCreateCommand": "make dev-bootstrap",
"postStartCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}"
}DevPod: the right architecture, and the maintenance bill that comes with it
DevPod is the cleanest design in the category. It is client-only: no server, no control plane to secure, no database to back up. Providers are pluggable, so the command that starts a container on a workstation starts a pod on a cluster. It is MPL-2.0, which matters more than any feature on a comparison table.
It is also stalled, and the specifics matter because the dramatic version is checkable and wrong. The last stable release is v0.6.15, published 10 March 2025. Everything since is a prerelease, spread across two tag series rather than one clean alpha line, and releases/latest still resolves to v0.6.15 — with 175 commits landed on main afterwards, the newest dated 14 November 2025 and none in 2026. The Kubernetes provider has had no release since v0.1.19 in February 2025. Surrounding signals agree: a “Still Maintained?” issue open since 11 September 2025, kept alive against the stale bot by community members with no maintainer answer, and a vendor that renamed itself to vCluster Labs on 15 August 2025 in an announcement naming vCluster and vNode but not DevPod.
Three honest options follow. Pin the last stable and accept frozen tooling — defensible for a small team whose definitions live in devcontainer.json anyway. Fork it and budget the engineering. Or keep it as a local convenience while your platform standard is the spec plus a server-side runtime. What is not defensible is adopting it strategically without naming its maintainer.
Coder: a server-side platform with Terraform as the workspace API
Coder is the opposite architecture and the opposite maintenance picture: a server you run, actively released — mainline v2.36.0 shipped on 4 August 2026, with the stable line one minor behind, so pin deliberately. Its workspace API is Terraform: a template is a module, a workspace is an apply. Workspaces inherit the provisioning discipline you already have, and that is the coupling you accept.
Read the licences before the feature matrix. The core is AGPL-3.0. Code under the repository's enterprise directory is not: it carries a separate LICENSE.enterprise whose terms state you may not move, change, disable or circumvent the licence-key functionality. A boundary worth drawing before adoption, not a criticism. The gate worth checking yourself is prebuilt workspaces, which removes cold-start latency: the docs list a Premium licence and coder/coder provider 2.4.1 or newer as prerequisites. Gates move between releases, so trust the current pricing page over any feature table, this one included.
Disconnected operation is well served. Coder documents that all features are supported in air-gapped deployments, and licence keys are signed JWTs validated locally, so a licensed deployment needs no outbound connection to stay licensed. Offline requirements are ordinary: a Terraform binary, a provider mirror, an external PostgreSQL. Whatever downloads quietly at runtime breaks first offline — the lesson from self-hosted CI runners.
For dev containers, Coder offers two models: an integration running @devcontainers/cli plus Docker inside the workspace, and envbuilder, which builds the workspace image itself from devcontainer.json. Envbuilder is Apache-2.0 in its own repository, so the piece reading your contract is separately licensed from the server scheduling it. Choose deliberately — the choice sets your security posture. The CLI model needs a Docker daemon in the workspace, a mounted socket or Docker-in-Docker; envbuilder needs none. Where you have just refused privileged builders only one is coherent, so the template below takes the envbuilder path.
terraform {
required_providers {
coder = { source = "coder/coder", version = "~> 2.4" }
kubernetes = { source = "hashicorp/kubernetes", version = "~> 2.38" }
}
}
data "coder_workspace" "me" {}
resource "coder_agent" "main" {
# os and arch are the only required arguments. Do not set dir: it is
# deprecated, and any value but the home directory breaks Desktop file
# sync. Strip it from templates inherited from the provider docs.
os = "linux"
arch = "amd64"
}
# No count: the claim outlives the pod that Coder stops on idle.
resource "kubernetes_persistent_volume_claim" "home" {
metadata {
name = "ws-home-${data.coder_workspace.me.id}"
namespace = "dev-workspaces"
}
# WaitForFirstConsumer classes never bind before a pod exists, so this
# provider's default wait-for-Bound deadlocks the apply.
wait_until_bound = false
spec {
access_modes = ["ReadWriteOnce"]
resources {
requests = { storage = "100Gi" }
}
}
}
resource "kubernetes_pod" "workspace" {
count = data.coder_workspace.me.start_count
metadata {
name = "ws-${data.coder_workspace.me.id}"
namespace = "dev-workspaces"
}
spec {
# Check the pod schema for your provider version: it does not track
# upstream fields one-for-one.
container {
name = "workspace"
# envbuilder IS the workspace image: it clones the repo, builds
# devcontainer.json in place, then execs the init script inside the
# result. No Docker socket, no DinD. Do not set command: that replaces
# the builder entrypoint. Digest-pin for a real fleet.
image = "ghcr.io/coder/envbuilder:latest"
env {
name = "ENVBUILDER_GIT_URL"
value = "https://git.internal.example.com/platform/service-a.git"
}
# Default is "sleep infinity"; run the agent instead, so the workspace
# reaches ready inside the environment just built.
env {
name = "ENVBUILDER_INIT_SCRIPT"
value = coder_agent.main.init_script
}
# Clone onto the claim; this path tracks remoteUser, not the agent name.
env {
name = "ENVBUILDER_WORKSPACE_BASE_DIR"
value = "/home/dev/workspaces"
}
# Layer cache in the internal registry: the only build egress needed.
env {
name = "ENVBUILDER_CACHE_REPO"
value = "registry.internal.example.com/dev/envbuilder-cache"
}
env {
name = "ENVBUILDER_EXIT_ON_BUILD_FAILURE"
value = "true"
}
env {
name = "CODER_AGENT_TOKEN"
value = coder_agent.main.token
}
# Home is the persistent volume; dir stays unset.
volume_mount {
name = "home"
mount_path = "/home/dev"
}
}
volume {
name = "home"
persistent_volume_claim {
claim_name = kubernetes_persistent_volume_claim.home.metadata[0].name
}
}
}
}Running the inner loop on your own Kubernetes
The reflex answer to “developers need to build images” is a privileged pod with a Docker socket — how most self-hosted dev platforms quietly become the least defensible namespace in the cluster. User namespaces are the structural fix: a pod opts in with spec.hostUsers: false, root inside the container is unprivileged outside it, and the Kubernetes documentation notes several HIGH or CRITICAL vulnerabilities that were not exploitable with them active.
State the floor before anyone pastes a manifest. User namespaces are stable and on by default as of Kubernetes v1.36. The prerequisites are node-level: idmap mount support on the filesystem backing kubelet pod directories and on every filesystem in the pod's volumes — Linux 6.3 or newer in practice — plus crun 1.9+ or runc 1.2+, and containerd 2.0+ or CRI-O 1.25+.
Below the floor there is no single failure mode, which is why “it deployed” proves nothing. KEP-127 records the split. A kube-apiserver older than 1.25 drops the unknown field and creates the pod without a user namespace. A kubelet with the feature switched off ignored hostUsers before 1.30; from 1.30 it fails to create the pod instead. The runtime is the quiet case: containerd 1.6 ignores the request, containerd 1.7 errors. Missing kernel or idmap support fails creation outright. Plan for all three — a hard create failure, a loud runtime error, and, on old runtimes only, a pod running happily with host UIDs.
Either way, do not fake it. User namespaces make root-in-container safe without providing a build daemon, so pair them with a rootless builder — buildah or kaniko — or let envbuilder or CI produce the image outside the workspace.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: ws-e17-home
namespace: dev-workspaces
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 100Gi
---
apiVersion: v1
kind: Pod
metadata:
name: ws-e17
namespace: dev-workspaces
labels:
app.kubernetes.io/name: workspace
spec:
# Verify the mapping below; do not assume it.
hostUsers: false
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
containers:
- name: workspace
image: registry.internal.example.com/dev/base-jammy@sha256:9f2c0b1a7d3e4f5061728394a5b6c7d8e9f0a1b2c3d4e5f60718293a4b5c6d7e
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
volumeMounts:
- name: home
mountPath: /home/dev
volumes:
- name: home
persistentVolumeClaim:
claimName: ws-e17-home
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: workspace-egress
namespace: dev-workspaces
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: workspace
policyTypes: ["Egress"]
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- { protocol: UDP, port: 53 }
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: platform-registry
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: platform-git
ports:
- { protocol: TCP, port: 443 }Selecting the pod for Egress with an explicit rule list makes everything unlisted denied; there is no separate deny rule to forget. Confirm two details against your own cluster: NodeLocal DNSCache does not answer to k8s-app: kube-dns, and resolvers falling back to TCP need port 53 on both protocols. Then verify rather than assert — a typo in a namespace label denies nothing. Test egress at connect time, never by resolving a name: the DNS rule above is deliberate, so public names keep resolving inside a workspace whose TCP egress is entirely denied. Resolvable is not reachable.
#!/usr/bin/env bash
set -euo pipefail
NS=dev-workspaces
POD=ws-e17
# Necessary, not sufficient: this only proves the API server stored it.
HU=$(kubectl -n "$NS" get pod "$POD" -o jsonpath='{.spec.hostUsers}')
[ "$HU" = "false" ] || { echo "FAIL: hostUsers=${HU:-unset}" >&2; exit 1; }
# Sufficient: prove the kernel mapping. Outside a user namespace the map is
# the identity "0 0 4294967295"; inside one, container UID 0 maps elsewhere.
MAP=$(kubectl -n "$NS" exec "$POD" -- cat /proc/self/uid_map |
awk 'NR==1 {print $2, $3}')
[ "$MAP" != "0 4294967295" ] || {
echo "FAIL: identity uid_map; the pod is not user-namespaced" >&2
exit 1
}
echo "ok: user namespace active, container UID 0 maps to host ${MAP%% *}"
# Probe the connect, not the name. Assert curl exists: absent, it exits
# non-zero and reads as "denied".
kubectl -n "$NS" exec "$POD" -- command -v curl >/dev/null
if kubectl -n "$NS" exec "$POD" -- \
curl -sS --connect-timeout 5 -o /dev/null https://proxy.golang.org/; then
echo "FAIL: public TCP egress succeeded; the policy denies nothing" >&2
exit 1
fi
echo "ok: public egress denied at connect time"
# Any status proves the connect; 401 is a pass.
kubectl -n "$NS" exec "$POD" -- curl -sS --connect-timeout 5 -o /dev/null \
https://registry.internal.example.com/v2/
echo "ok: internal registry reachable on 443"
# The substitution test: the contract must build without the platform.
npx --yes @devcontainers/cli build --workspace-folder . --no-cacheFailure modes that only show up in week three
None of these appear in a four-workspace proof of concept. All appear once forty engineers are on the platform.
- ReadWriteOnce home volumes pin engineers to nodes. A workspace with an RWO PVC only reschedules onto a node that can attach that volume, so draining for an upgrade makes it wait. Drain against workspace hours, or budget for ReadWriteMany.
- Idle workspaces bill as nodes, not as hours. Self-hosting moves the meter, it does not remove it: without an idle-stop policy, forty workspaces at 8 vCPU pin capacity permanently. Scale to zero on inactivity, with the same event-driven autoscaling machinery as the rest of the cluster.
- A default-deny workspace breaks every implicit download. Toolchains, extension marketplaces, features from a public registry, and
npxitself all reach outward. Mirror what you depend on and pin it, or watch an allowlist grow until it means nothing. Secrets are the mirror image: a credential baked into a shared base layer is readable by everyone who can pull it, so inject at runtime. - Some definitions silently need Docker. A
docker-compose.yml-based devcontainer, or apostCreateCommandshelling out todocker, will not run in an unprivileged pod. Discover that during migration, not on a first day.
One more, organisational: an inner-loop platform becomes load-bearing fast, because when it is down nobody works. Give it the blast-radius thinking you give production — namespace-per-team at minimum, or virtual clusters where teams need their own CRDs. And if you serve a self-hosted coding assistant, the workspace is its client: one more allowlist entry, one more availability dependency.
What data residency does not buy you
The pitch for self-hosted dev environments is usually “code never leaves the building,” and the residency half of that argument is weaker than it sounds. GitHub's own documentation states that Codespaces on GHE.com is available in all GitHub Enterprise Cloud data-residency regions. If the requirement is that source sits in a particular jurisdiction, the incumbent answers it — and leading with residency invites a procurement conversation you lose.
The defensible case is narrower and stronger, and about control rather than geography. Egress: you decide what a workspace holding your source may reach, and can prove the policy denies the rest. Provenance: the base image is one you built, digest-pinned, from a registry you run. Privileged access: the people who could obtain a plaintext checkout are a set you enumerate. Evidence: the audit log records who reached which workspace and when, in your retention. Where the controlled event is a person obtaining information rather than data crossing a border — export-controlled work is the sharpest example — a region claim answers the wrong question.
Exit ramps and the long game
Write the substitution path down before you need it, both directions. Off a hosted CDE: the definition is already devcontainer.json, so migration is a scheduling problem, not a rewrite. Off whichever runtime you pick: the same file must build under the plain reference CLI, rehearsed quarterly. If it succeeds the runtime is a choice; if it fails you adopted a product with a specification-shaped front door.
Price the licence facts in like any other exit cost. MPL-2.0 is why an abandoned tool can be forked by anyone — the DevPod situation is unfortunate, not fatal, and the licence is why. AGPL-3.0 plus a separate enterprise licence is a different shape: the core is yours to run and modify, and a specific set of capabilities is a commercial decision to make deliberately rather than discover mid-migration.
The long-game position is unglamorous: the inner loop should outlive every tool named here. Own the definition, keep the runtime replaceable, and which vendor is maintained this year stops being an architectural risk. It becomes a procurement detail.
§FAQ/Common questions
Frequently asked
Is DevPod still maintained in 2026?
Not in any sense you should depend on. The last stable release is v0.6.15, published 10 March 2025; everything published since is a prerelease, and the releases/latest endpoint still resolves to v0.6.15 despite 175 commits landing on main afterwards. The newest commit on main is dated 14 November 2025, with none in 2026, and the official Kubernetes provider has had no release since v0.1.19 in February 2025. A "Still Maintained?" issue opened on 11 September 2025 has had no maintainer reply. Because the project is MPL-2.0, a community fork has been able to pick it up and ship its own release line — evidence the exit is open, not a supported production path.
DevPod or Coder for a self-hosted developer platform?
For a platform standard in 2026, Coder is the maintained choice: it shipped v2.36.0 on 4 August 2026 and is actively released, with Terraform as its workspace API. DevPod's client-only architecture is arguably cleaner and needs no server, but its upstream has cut no stable release since March 2025. The more important answer is that the question should not be load-bearing. If your standard is devcontainer.json, either runtime is a swap; if your standard is the tool, you have already made the expensive choice.
Can you run dev containers on Kubernetes without a privileged pod?
Yes, with prerequisites. A pod sets spec.hostUsers: false so that a process running as root in the container is unprivileged on the host; user namespaces are stable and enabled by default as of Kubernetes v1.36, and the Kubernetes documentation notes several HIGH or CRITICAL vulnerabilities that were not exploitable with them active. The node needs idmap mount support on the kubelet pod directory and on every filesystem used in the pod's volumes (in practice Linux 6.3 or newer), crun 1.9+ or runc 1.2+, and containerd 2.0+ or CRI-O 1.25+. User namespaces do not provide a build daemon, so pair them with a rootless builder such as buildah or kaniko, or build images outside the workspace.
Does self-hosting dev environments solve data residency?
It is the wrong argument to lead with. GitHub documents that Codespaces on GHE.com is available in all GitHub Enterprise Cloud data-residency regions, so the incumbent can answer a residency requirement directly. The defensible reasons to self-host the inner loop are control-shaped rather than geographic: you set and can prove the workspace's egress policy, you own the base image and its provenance, you can enumerate every person able to obtain a plaintext checkout, and the API-server audit log gives you the access evidence on your own retention terms.
Which Coder features require a paid licence?
Treat any feature matrix, including this one, as perishable and check the current pricing page. The gate worth knowing about is prebuilt workspaces, the feature that removes cold-start latency: the documentation lists a Premium licence and the coder/coder Terraform provider at 2.4.1 or newer as prerequisites. Structurally, Coder's core is AGPL-3.0 while code under the repository's enterprise directory carries a separate LICENSE.enterprise, whose terms state you may not move, change, disable or circumvent the licence-key functionality. Licence keys themselves are signed JWTs validated locally, so a licensed deployment stays licensed with no outbound connection.
Further reading
- Platform engineering: golden paths and policy enforcement
- Self-hosted GitHub Actions runners on Kubernetes with ARC
- A sovereign self-hosted coding assistant
- Modelling vendor lock-in exit cost
- vCluster and hard multi-tenancy on Kubernetes
- ITAR workloads and the US-person access enclave
- Platform and infrastructure engineering
Executive Briefing
Thirty minutes to clarify your infrastructure risk
Walk us through your vendor footprint and regulatory constraints. We will tell you honestly where sovereignty creates leverage — and where it does not. No pitch deck. No obligation.