all posts

Where to Run devcontainer.json in 2026

Ajay Kumar··9 min read

I build PandaStack, an open-source Firecracker microVM platform, and the devcontainer conversation arrives in a reliable shape. Someone has a `devcontainer.json` that works on their laptop, and they want to know where else they can run it. Underneath that, usually unseparated, is a second question: where is it safe to run it. The spec answers the first question extremely well and is completely silent on the second.

That is not a criticism. The Dev Container specification — an open spec with a reference CLI, maintained in the open rather than inside one vendor — is one of the better things to happen to developer tooling in a decade. A text file in the repo that half a dozen independent tools can read and turn into a working environment is a real achievement, and the portability is not marketing. You can take that file to a different vendor and it mostly works. Almost nothing else in this category can say that.

But the thing to internalise before you pick a host: `devcontainer.json` is a build-and-setup contract, not an isolation contract. It names an image, layers features, forwards ports, runs lifecycle commands, and tells your editor which extensions to install. There is no field in it that describes what the workspace is isolated from. Two fully compliant platforms can hand you radically different blast radii — a container sharing a host kernel with other tenants, or a dedicated virtual machine — from a byte-identical file.

The short version: the spec gives you reproducibility and portability, and the host gives you the boundary. Evaluate those separately. Pick the spec implementation on prebuild quality, feature support and whether you can self-host it; pick the isolation on what you are willing to have `npm install` reach. And install the `devcontainer` CLI regardless — it is the thing that makes the file portable away from whoever you choose.

What devcontainer.json actually specifies

Four groups of keys, and they bind very different things. It is worth being precise about which is which, because most arguments about devcontainers are really arguments about one group being mistaken for another.

// .devcontainer/devcontainer.json -- devcontainer.json is JSONC, so comments
// are legal. Annotated by what each key actually BINDS.
{
  "name": "api-service",

  // === BUILD CONTRACT. Every compliant tool must honour these. ===
  "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04",

  // Each feature is an OCI artifact: devcontainer-feature.json + install.sh,
  // run as root in a layer on top of the image. ":1" is a floating MAJOR tag --
  // this line is not reproducible across months. Pin ":1.6.2" or a digest.
  "features": {
    "ghcr.io/devcontainers/features/node:1": { "version": "24" },
    "ghcr.io/devcontainers/features/python:1": { "version": "3.12" }
  },
  // Install order is a topological sort over each feature's own installsAfter.
  // This key pins the front of that list when the sort guesses wrong.
  "overrideFeatureInstallOrder": [
    "ghcr.io/devcontainers/features/common-utils"
  ],

  // === SETUP CONTRACT. Shell, in spec order. ===
  // Runs on the HOST, before any container exists. Not in your sandbox.
  "initializeCommand": "scripts/check-host-deps.sh",
  "onCreateCommand": "sudo apt-get update && sudo apt-get install -y libpq-dev",
  "updateContentCommand": "npm ci --no-audit --fund=false",
  // The expensive one, and the one a prebuild usually cannot bake.
  "postCreateCommand": "npm run db:migrate && npm run codegen",
  "postStartCommand": "npm run services:up",
  "waitFor": "updateContentCommand",

  // === PLUMBING. Honoured differently per implementation. ===
  "containerEnv": { "DATABASE_URL": "postgres://dev@localhost:5432/dev" },
  "remoteUser": "node",          // UID remap happens here on Linux hosts
  "updateRemoteUserUID": true,
  "forwardPorts": [3000, 5432],  // needs a client attached to mean anything
  "hostRequirements": { "cpus": 4, "memory": "8gb" },  // a request, not a cap

  // The only security-shaped keys in the whole file -- and all four WIDEN the
  // container. There is no key that narrows the boundary.
  "runArgs": [],
  "privileged": false,
  "capAdd": [],
  "securityOpt": [],

  // === EDITOR PREFERENCE. Binds nothing about the environment. ===
  "customizations": {
    "vscode": {
      "extensions": ["dbaeumer.vscode-eslint", "ms-python.python"],
      "settings": { "editor.formatOnSave": true }
    }
  }
}

The build contract is `image`, or `build` with a Dockerfile, or `dockerComposeFile` with a service name, plus `features`. This is the part with teeth: any compliant tool must produce the same filesystem from it, and that is where the reproducibility comes from.

The setup contract is the lifecycle commands. These are shell strings run in a defined order at defined moments, and they are where the actual work of a dev environment happens — dependency installs, migrations, codegen, service startup. They are also, as we will get to, where your cold start lives.

The plumbing is `forwardPorts`, `containerEnv`, `remoteEnv`, `mounts`, `remoteUser`, `hostRequirements`. Every one of these is honoured, but honoured differently depending on what the implementation actually is. `forwardPorts` means a tunnel on a managed platform, a published port locally, and precisely nothing in a headless CI run with nothing attached. `hostRequirements` is a request about machine size, which a vendor may use to pick an instance type and a local Docker will ignore.

And the editor preference is `customizations`, a vendor-namespaced bag where `vscode.extensions` and `vscode.settings` live. This section is excellent and I am not sniffing at it — getting the same linter and the same format-on-save for every person on a team is worth real money. It is just not a property of the environment. If a tool ignores the whole block the code still builds.

A build contract, not an isolation contract

Go back and look at the four security-shaped keys in that file: `runArgs`, `privileged`, `capAdd`, `securityOpt`. Notice the direction all four of them point. Every one of them is an escape hatch for widening what the container can do — give it more capabilities, relax a seccomp profile, pass extra flags to the runtime. There is no key that narrows the boundary. There is no `isolation: vm`, no `network: deny-egress`, no per-workspace policy of any kind. The spec does not have an opinion about confinement, because confinement was never its job.

Which matters because of what a dev environment does on first boot. `updateContentCommand` is almost always a package install, and a package install runs arbitrary code from the internet as a documented feature — `postinstall` scripts, `setup.py`, build hooks, native extensions compiling against whatever they feel like. A branch's lockfile is untrusted code the moment you resolve it. Add an AI coding agent to the environment and you have made that worse on purpose: now a model is proposing commands too, and a model that writes a confident `rm -rf` does so with exactly the same privileges as the hook that ran before it.

A container is a kernel politely honouring a request about namespaces and cgroups. It is a very good request. It is not a wall, and the difference shows up precisely once, at the worst possible moment, in a kernel CVE advisory. On a shared-kernel platform the question "what else is on this host" has an answer you are not allowed to know.

So the spec's portability story and the spec's security story are different stories, and only one of them is in the file. There are two honest ways to use that fact. Either run the spec on a host whose boundary you already accept — your own laptop, your own VM, your own cloud account — or pair the spec's reproducibility with an isolation layer underneath that is stronger than a namespace. What you should not do is read "fully supports the Dev Container specification" on a pricing page and conclude anything at all about blast radius.

The parts of the spec people get wrong

Five of them, in rough order of how much time each one has cost people I have talked to.

Features are OCI artifacts, and `:1` floats

A feature is not a config option. It is a published OCI artifact containing a `devcontainer-feature.json` and an `install.sh`, which the CLI pulls from a registry and runs as root in a layer on top of your base image. That is a genuinely elegant design — it makes environment setup composable and shareable — and it has two consequences people miss.

First, order. Each feature declares `installsAfter` in its own metadata, and the CLI topologically sorts the set you asked for. When that sort produces the wrong answer — a language runtime installed before the shell utilities it wants, a feature that assumes a user already exists — `overrideFeatureInstallOrder` in your `devcontainer.json` lets you pin the front of the list. If a feature combination works for one person and breaks for another, resolution order is the first place to look, and the resolved order is something you can print rather than guess at.

Second, and bigger: `ghcr.io/devcontainers/features/node:1` is a floating major-version tag. It resolves to whatever the latest 1.x publish is on the day you build. So is every other `:1` in every example you have copied, including the one above. Your "reproducible" environment is reproducible within a build and drifts across months, and the drift is invisible because the file did not change. Pin the full version, or pin a digest, and accept that you now have to bump them like any other dependency. A feature's `install.sh` that calls `apt-get install` floats too, independently, against the distro's repository.

Six lifecycle hooks, and one of them runs on your laptop

In spec order: `initializeCommand`, `onCreateCommand`, `updateContentCommand`, `postCreateCommand`, `postStartCommand`, `postAttachCommand`. Most people know two of these and assume the rest are synonyms. They are not, and the distinctions are where the interesting behaviour lives.

  • `initializeCommand` runs on the host, before any container exists. Read that twice. Opening a repository you did not write, in a tool that honours the spec, can execute a command on the machine you are sitting at. Editors trust-gate this and you should let them; it is also a good reason not to point an unattended pipeline at a `devcontainer.json` from a fork.
  • `onCreateCommand` is the first hook inside the environment, during creation. This is the right home for anything that is a property of the image rather than the checkout — system packages, a database cluster bootstrapped.
  • `updateContentCommand` runs during creation and again whenever content is refreshed. This is where dependency installs belong, and it is what `waitFor` defaults to.
  • `postCreateCommand` runs once after the environment is assigned to you. Migrations, codegen, seeding, the stuff that needs both the dependencies and the checkout.
  • `postStartCommand` runs on every start, including a resume from stopped. Start your services here, not in postCreate, or they come back dead after a resume.
  • `postAttachCommand` runs every time a tool attaches. Cheap things only — this one fires more often than you think.

Each hook takes three forms, which is worth knowing: a string, run through a shell; an array, exec'd directly with no shell, so your `&&` and `$VAR` do not expand; or an object of named commands, which the CLI runs in parallel. That last form is the easiest way to cut setup time and almost nobody uses it, presumably because every example on the internet is a string.

`postCreateCommand` is your cold start, not the image pull

When somebody says a devcontainer platform is slow, the stopwatch is almost never measuring the thing they blame. Pulling a cached image is seconds. Installing dependencies for a real application is minutes. On any repository with a non-trivial lockfile, the lifecycle commands dominate the create time by an order of magnitude, and the platform you chose is barely a variable in it.

Prebuilds are the correct answer and they are partial by construction. A prebuild bakes the image plus the hooks that run during creation, which is why your `updateContentCommand` gets cached and your `postCreateCommand` does not — the latter runs after the environment is handed to a user, which is the entire definition of the hook. So the useful optimisation is not "turn on prebuilds", it is "move everything that is a function of the lockfile earlier than `postCreateCommand`, and leave only what genuinely needs the live workspace behind". Check your platform's current documentation for exactly which hooks its prebuilds cover; this is the kind of detail that moves between releases and is not worth taking from a blog post, including this one.

The structurally different answer, and the one I build, is to stop re-running setup at all: do the install once, snapshot the configured machine's memory and disk, and restore that snapshot per environment. Then the dependency install is not fast, it is absent — the environment comes back with the install already in RAM. That is a different shape of solution with its own costs, and I come back to it below.

`remoteUser`, and the UID you did not choose

Dev container images conventionally ship a non-root user — `vscode`, `node`, `codespace` — usually at UID 1000. Your workspace is a bind mount from the host. If your host user is also UID 1000, which on a single-user Linux desktop it usually is, everything lines up and you never learn this exists. If it is not — a second account on the box, a corporate directory handing out UIDs in the hundreds of thousands, a CI runner on a service account — then files written inside the container land on the host owned by a user who is not you, or the container cannot write files the host created.

The spec's answer is `updateRemoteUserUID`, on by default on Linux hosts, which has the CLI rewrite the container user's UID and GID to match your local user. It works, and it is one of the better pieces of papering in the whole spec. It is also papering: the underlying problem is that there are two sets of user identities and one filesystem between them. macOS and Windows users never see it because Docker Desktop's file-sharing layer translates ownership for them, which is why this bug reliably reaches the Linux half of a team and nobody else. Rootless Docker and Podman reintroduce it in a new shape via user-namespace remapping.

Worth noticing where the problem comes from: it exists because the workspace is a bind mount from a machine with other users on it. An environment whose whole root filesystem is the workspace has no second identity namespace to reconcile. You trade the bind-mount convenience — edit on the host, build in the container, same inode — for not having the class of bug at all.

The `devcontainer` CLI is the escape hatch

The reference implementation is a CLI you can install from npm and run anywhere, under a permissive licence. This is the single most underused thing in the ecosystem, and it is what converts "we are on a devcontainer platform" into "we have a portable environment definition".

  • `devcontainer up --workspace-folder .` resolves the config, builds the image with features, starts the container and runs the creation hooks. It prints JSON including the container id and resolved remote user — scriptable.
  • `devcontainer build --workspace-folder . --image-name myorg/dev:sha` does the build half only, producing an ordinary OCI image. This is the command that matters most, because an ordinary image goes anywhere an image goes: a registry, a CI job, a Kubernetes pod, or the input to a completely different runtime.
  • `devcontainer exec --workspace-folder . <cmd>` runs a command in the started environment with the right user and environment, which makes a devcontainer a usable CI executor with no vendor involved.
  • `devcontainer read-configuration --workspace-folder .` prints the merged, resolved configuration. Use this before arguing about what a file does — it answers the feature-order question empirically.

If your fear about adopting any of this is vendor lock-in, that fear is mostly answered by the CLI existing. The format is open, the resolver is open, and `devcontainer build` turns your environment definition into an artifact with no spec-shaped edges on it at all.

Where you can run devcontainer.json in 2026

Qualitative, because the honest version of a comparison like this is qualitative. Pricing, machine types, idle timeouts and which lifecycle hooks a prebuild covers all move faster than any post can track, and several of these products have been renamed or re-architected inside the last two years. Verify every operational detail against the vendor's current documentation rather than against me.

GitHub Codespaces is the reference implementation in practice. It reads the spec most completely, prebuilds are a first-class feature with repository-level configuration, the editor integration is the best in the category, and there is a button on the pull request. It is also the easiest decision to defend if your code is already on GitHub and your team already lives in VS Code. The boundary underneath is a managed virtual machine per codespace — check GitHub's documentation for the current architecture — which is a reasonable boundary that you do not administer and cannot inspect.

The `devcontainer` CLI with local Docker is the baseline everyone should have working, and it is free. Spec coverage is total by definition. The layer cache is your own, which makes it the fastest iteration loop in this list by a wide margin. The isolation boundary is your laptop's kernel, running a container whose first act is to install dependencies from the internet — fine for your own code, and a thing to think about for a fork's pull request.

DevPod is open source and client-side: no server to operate, and a pluggable provider model that puts the spec on local Docker, an SSH target, a cloud VM or a Kubernetes cluster. If the value you want from a managed platform is "the devcontainer works everywhere" rather than "somebody else runs the machines", this removes the vendor without removing the format. The isolation boundary is whatever the provider gives you, which is the honest answer and also the point — you choose it.

Coder is the self-hosted, platform-team answer: a control plane you run, workspaces defined as templates, and audit and access controls aimed at organisations where the code may not leave their own cloud. Dev Container support exists alongside its native templating; check which integration path is current, because this has evolved. The isolation boundary is determined by the template you write — a pod, a container, a VM — so it is as strong as you make it, and it is genuinely yours.

Gitpod, now Ona, moved from its own `.gitpod.yml` format toward the Dev Container spec across its generations, and both the product name and the self-hosting story changed on the way. It remains a serious product with real engineering in it. It is also the entry on this list where I would most insist on reading the current documentation rather than anything written about it, mine included.

The VS Code Dev Containers extension plus Remote-SSH is the quiet option. The extension is the spec's front end; Remote-SSH points your editor at any machine you can reach. Put the two together and a plain Linux box from a budget provider gives you most of what a managed platform gives a small team, for a small fraction of per-seat pricing. What you do not get is per-pull-request fan-out, a web editor, or anybody else's on-call rotation.

And rolling it yourself on a VM is the floor: a box, Docker, the CLI, SSH. Cheapest per hour by a distance, and you own the kernel, the patching, the backups and the question of what "ephemeral" means when the only thing deleting environments is a cron job you wrote on a Friday.

Dev Container spec implementations, isolation boundary and prebuild story
PlatformSpec coverageIsolation boundary underneathPrebuild / cacheSelf-hostable
GitHub CodespacesMost complete in practiceManaged VM per codespace (verify current docs)First-class repo prebuildsNo
devcontainer CLI + DockerTotal (reference impl)Your machine's kernelYour local layer cacheIt is already yours
DevPodStrong, open sourceWhatever the provider isPrebuilt images supportedYes, no server at all
CoderSupported alongside native templatesWhatever your template definesTemplate- and registry-dependentYes, that is the product
Gitpod / OnaMoved onto the spec; verifyManaged; changed across generationsDocumented, verify currentChanged across generations
VS Code Dev Containers + Remote-SSHTotal (same resolver)The box you pointed it atThat box's Docker cacheYes
Roll it on a VMTotal (you run the CLI)Your VM, your kernelYours to buildYes, all of it
PandaStackNone — no implementationDedicated Firecracker microVM, own kernelTemplate snapshot restored per createYes, Apache-2.0

PandaStack does not implement the Dev Container spec

That last row is the honest note in this post, and I would rather state it plainly in a section of its own than bury it in a table. PandaStack has no Dev Container implementation. There is no `devcontainer.json` ingestion, no feature resolver, no browser IDE, no button on the pull request. If what you want is to point a product at a repository and have a human open an editor in it, this is not that product and you should use one of the ones above.

What PandaStack is, is the other half: an API that creates hardware-isolated microVMs, each with its own kernel, in about 179 ms at the median — because every create is a restore of a snapshot that was baked once, not a boot. There is no warm pool of idle machines behind that number. A first spawn of a new template does a cold boot and bakes the snapshot, around 3 seconds, and everything after it is a restore.

Environments come from templates, not from a spec file. A template is built from a Dockerfile, and the pipeline is worth knowing because it determines exactly how much of the Dev Container spec you can reuse: `pandastack template build -f Dockerfile -n <name>` runs `docker build` on your machine, then `docker create` and `docker export` to flatten the result to a tar, uploads that, and the platform writes it to an ext4 image and bakes a Firecracker snapshot from it. The input is an ordinary OCI build. Which means the output of `devcontainer build` is, with one wrapper, a valid input.

Honouring the contract without the runtime

So here is the pattern, and I want to be exact about what it is: something you assemble, not a feature I ship. It works by splitting the file along the seam that was already there. The build contract — `image` plus `features` — is resolved once, outside, on a machine that has Docker, by the reference CLI. The setup contract — the lifecycle commands — is executed per sandbox as plain exec steps. Neither half needs a container runtime inside the guest, because the guest is already the isolated environment; there is nothing left for a container to isolate you from.

Two things do not survive the seam, and both are in the comments below. `docker export` flattens a container's filesystem, so `ENV`, `CMD` and `ENTRYPOINT` are discarded — a microVM is not started by a Docker runtime, so anything the image set in the environment has to be written to a file the guest reads at boot. And the rootfs needs an init system and an sshd to be a bootable microVM at all; a dev container base image ships neither, because it was never meant to boot.

# Honour a devcontainer.json's SETUP contract inside a PandaStack microVM.
#
# What this does NOT do: run `devcontainer up`. That needs a container runtime,
# and the `base` template ships none -- no docker, no containerd, no podman.
# So the BUILD half of the contract is resolved outside, once, on a machine
# that does have Docker, and baked into a template:
#
#   devcontainer build --workspace-folder . --image-name devc-local:latest
#   cat > Dockerfile.psb <<'EOF'
#   FROM devc-local:latest
#   # The microVM rootfs contract: /sbin/init or the kernel falls through to
#   # /bin/sh and nothing boots; sshd is the host<->guest exec bridge.
#   RUN apt-get update && apt-get install -y --no-install-recommends \
#         systemd systemd-sysv openssh-server sudo && rm -rf /var/lib/apt/lists/*
#   # `docker export` flattens the filesystem: ENV, CMD and ENTRYPOINT do NOT
#   # survive. Anything the image set in ENV has to be written to a file.
#   RUN printf 'PATH=/usr/local/bin:/usr/bin:/bin\n' > /etc/environment
#   EOF
#   pandastack template build -f Dockerfile.psb -n devc-api --memory-mb 4096
#
# ...and this script executes the LIFECYCLE half, per sandbox, with no runtime.

import json
import shlex

from pandastack import Sandbox
from pandastack.exceptions import CommandFailed

REPO = "https://github.com/myorg/api-service"
WS = "/workspace/repo"

# Spec order. initializeCommand is deliberately absent: the spec runs it on the
# HOST, and a repo I did not write does not get a shell on my laptop.
HOOKS = ["onCreateCommand", "updateContentCommand",
         "postCreateCommand", "postStartCommand"]


def as_commands(value):
    """string | list | {name: string|list} -> list of shell strings."""
    if isinstance(value, str):
        return [value]
    if isinstance(value, list):                  # array form = exec, no shell
        return [" ".join(shlex.quote(a) for a in value)]
    if isinstance(value, dict):                  # object form = parallel group
        return [c for v in value.values() for c in as_commands(v)]
    return []


with open(".devcontainer/devcontainer.json") as fh:
    cfg = json.load(fh)                          # strip JSONC comments first

# containerEnv/remoteEnv have to be exported in the launching shell -- there is
# no Docker runtime here to inject them, and /etc/environment is baked, not live.
env = {**cfg.get("containerEnv", {}), **cfg.get("remoteEnv", {})}
exports = "".join(f"export {k}={shlex.quote(str(v))}; " for k, v in env.items())

# ttl_seconds is the platform backstop: the VM reaps itself even if this process
# is killed. cpu=/memory_mb= are omitted on purpose -- Firecracker cannot resize
# either at snapshot restore, so the template's baked meta.json wins regardless.
sbx = Sandbox.create(
    template="devc-api",
    ttl_seconds=1800,
    metadata={"repo": "myorg/api-service", "role": "devcontainer"},
)
try:
    sbx.exec(f"mkdir -p {WS}", timeout_seconds=20, check=True)
    sbx.exec_stream(f"git clone --depth 1 {REPO} {WS}", timeout_seconds=180)

    for hook in HOOKS:
        for cmd in as_commands(cfg.get(hook)):
            # The REAL bound is in-guest. One-shot exec(timeout_seconds=) is not
            # enforced by the agent and the HTTP client gives up at 30s anyway,
            # so long work goes through exec_stream (which does raise the client
            # timeout) and `timeout` in the guest does the actual killing.
            wrapped = (f"cd {WS} && {exports}"
                       f"timeout --kill-after=10s 600 sh -lc {shlex.quote(cmd)}")
            print(f"--- {hook}: {cmd}")
            code = sbx.exec_stream(
                wrapped,
                on_stdout=lambda s: print(s, end=""),
                on_stderr=lambda s: print(s, end=""),
                timeout_seconds=660,
            )
            if code != 0:
                raise CommandFailed(f"{hook} exited {code}", exit_code=code)

    # forwardPorts means nothing without an editor attached. The sandbox
    # equivalent is a preview URL per port, live for the VM's lifetime.
    for port in cfg.get("forwardPorts", []):
        print(port, sbx.preview_url(int(port)))
finally:
    sbx.kill()   # kill() IS the teardown -- the SDK has no delete method

What you get out of that: the same image your team's `devcontainer.json` describes, the same features at the same versions, the same setup commands in the same order, running on a dedicated kernel with no other tenant on it, created in a fraction of a second and destroyed unconditionally in a `finally`. What you give up: the editor, the attach, `forwardPorts` as a tunnel, and anything in `customizations`.

The split that actually works in practice is humans on a spec platform and machines on a sandbox API. A developer who wants to open a file and type in it should use Codespaces or a local devcontainer, with the editor and the debugger and the extensions. CI jobs, per-pull-request rigs and agents that fan out hundreds of environments an hour want an API and an isolation boundary, and they do not want an IDE. These are different products because they are different problems, and the `devcontainer.json` in the repo is the artefact both of them can read.

Honest limits

Everything above costs something. Here is the bill.

  • There is no Dev Container implementation here and I am not promising one. The pattern in this post is a script you maintain. When the spec adds a key, your script does not learn it.
  • No browser IDE, no attach, no pull-request button. `customizations.vscode` is inert — nothing reads your extension list. If your team's daily inner loop is the thing you are shopping for, this is the wrong aisle.
  • The `base` template ships no container runtime. No Docker daemon, no containerd, no podman. So `devcontainer up` does not work in a stock sandbox, and that is why this post shows the build happening outside. You can bake a template that installs a runtime, but I am not going to tell you it works, because the stock Firecracker guest kernel is missing several netfilter matches that container bridge networking leans on — we measured exactly this when evaluating Kubernetes in a guest: pods and pod-to-pod networking worked, and the service proxy's iptables batch failed atomically on a missing match module. Overlayfs, veth, bridges, conntrack and cgroups v2 are all present and working. Whether your particular runtime's networking is in the working set is a thing you would have to establish, not assume.
  • The guest kernel is 5.10, on Ubuntu 24.04. A feature whose install script wants something newer than that kernel offers will not work, and will probably fail in a way that does not mention the kernel.
  • Firecracker cannot resize vCPU or RAM at snapshot restore. A sandbox's size comes from its template's baked metadata, so `cpu=` and `memory_mb=` on a create are overridden to match the snapshot. `hostRequirements` in your `devcontainer.json` is not a knob here; it is a note telling you which template to bake. The first-party `base` template is baked at 4 GiB and 8 burstable vCPUs, with mise pre-warming Node 24, Python 3.12, Go and Bun.
  • Egress is open by default — there is no default-deny. The root forward chain does drop sandbox-to-sandbox traffic across the pool and all of 169.254.0.0/16, so no sandbox reaches another sandbox's subnet and cloud metadata is not reachable. Fencing your own VPC and your own databases off from a dependency install is still your work.
  • A bind mount from your laptop is genuinely convenient and you lose it. Editing on the host while building in the environment, with the same inode on both sides, is a nice loop. A microVM's filesystem is its own; you push code in and pull artefacts out.

The recommendation that falls out of all that is unexciting and I think correct. Keep the `devcontainer.json`. It is the most portable asset in your repository and the thing you would use to rebuild the environment from nothing. Install the CLI so the file is not hostage to one vendor. Choose your spec implementation on prebuilds, feature support and self-hosting. Then, separately, decide what boundary you want around the install step — and if the answer is "stronger than a shared kernel", that is a different layer and it does not require giving up the file.

Frequently asked questions

Does the Dev Container spec provide any security isolation?

No, and it is important to be precise about why. The specification describes how to build and set up a development environment: which image, which features to layer, which ports to forward, which lifecycle commands to run, which editor extensions to install. There is no field in devcontainer.json that describes what the workspace is isolated from. The only security-adjacent keys in the format — runArgs, privileged, capAdd and securityOpt — all point in the loosening direction; they let you give the container more privileges than it would otherwise have. Nothing in the format narrows a boundary. This means two fully compliant platforms can give you completely different blast radii from an identical file: one might run your workspace as a container sharing a host kernel with other customers' workloads, another might give it a dedicated virtual machine. Both are correct implementations of the spec. The practical consequence is that "fully supports the Dev Container specification" on a vendor's pricing page tells you about reproducibility and portability, and tells you literally nothing about isolation. Evaluate those two properties separately, and ask the host vendor directly what the boundary around a workspace is, because the spec will not tell you.

Why is my devcontainer slow to start even with prebuilds enabled?

Almost certainly because the expensive work is in postCreateCommand, and that is the one hook a prebuild structurally cannot bake. The lifecycle hooks run in a defined order — initializeCommand on the host, then onCreateCommand, updateContentCommand, postCreateCommand, postStartCommand, postAttachCommand — and a prebuild can capture the ones that happen during creation. postCreateCommand runs after the environment is handed to a user, which is the definition of the hook, so it runs on every create no matter what you cache. If your npm ci or pip install or bundle install lives there, you are paying for it every time. The fix is to move anything that is purely a function of the lockfile earlier, into updateContentCommand, and leave only genuinely per-workspace work in postCreateCommand. Also check whether you are using the object form of the hooks, which runs named commands in parallel — almost nobody does, because every example online is a single string, and a setup that does three independent things sequentially is wasting most of its wall clock. Confirm which hooks your specific platform's prebuilds cover against its current documentation; this detail moves between releases.

Can I run `devcontainer up` inside a PandaStack sandbox?

Not in a stock sandbox, and I would rather say so than imply otherwise. devcontainer up needs a container runtime to build and start the workspace container, and the base template ships none — no Docker daemon, no containerd, no podman. You could bake a custom template that installs one, and the guest does have the kernel pieces a container runtime needs most: overlayfs, veth, bridges, conntrack and cgroups v2 are all present and we have measured containers running and talking to each other inside a guest. But the stock Firecracker guest kernel is also missing several netfilter match modules, and when we evaluated a Kubernetes control plane in a guest the service proxy's iptables batch failed atomically on exactly one of those missing matches while pod networking worked fine. So whether your runtime's networking path lands in the working set is something you would need to establish empirically, and I am not going to claim it for you. The pattern that does work today is to split the spec along the seam that already exists: resolve the image and features outside, once, with devcontainer build on a machine that has Docker, bake that image as a PandaStack template, and then execute the lifecycle commands as plain exec steps in the sandbox. The guest is already the isolated environment, so there is nothing left for a container to isolate you from.

Is pinning feature versions in devcontainer.json actually necessary?

Yes, and this is the most common invisible reproducibility hole in the ecosystem. A tag like ghcr.io/devcontainers/features/node:1 is a floating major version: it resolves to whatever the latest 1.x publish happens to be on the day the image is built. Every copy-pasted example uses this form, including the annotated one in this post. So an environment definition that has not changed in six months can still produce a materially different filesystem today than it did in spring, and nothing in your git history will suggest why. If reproducibility is the reason you adopted devcontainers, pin the full version or pin a digest, and treat those pins like any other dependency that needs periodic bumping. Be aware that pinning the feature does not pin everything underneath it either — a feature whose install.sh calls apt-get install floats independently against the distribution's package repository, and the base image tag floats too unless you pinned that by digest as well. Full determinism needs all three pinned, which is more maintenance than most teams want; the honest middle ground is to pin the feature versions, because that is where the surprise changes actually come from, and accept the rest.

Should I replace devcontainers with microVM sandboxes?

No — for most teams the right answer is both, split by who or what is using the environment. A human developer who wants to open a file, type in it, set a breakpoint and have the right extensions already installed is served best by a Dev Container platform with a real editor integration, and nothing about a sandbox API competes for that job. There is no IDE here and no attach. But CI jobs, per-pull-request test rigs and AI agents that fan out dozens or hundreds of environments an hour want the opposite set of properties: an API rather than a UI, creation measured in milliseconds rather than minutes, a hard isolation boundary around code they did not review, and unconditional teardown. Those are different products because they are different problems. The useful part is that the devcontainer.json in your repository is the shared artefact: it is the most portable description of your environment you own, it is reviewed like code, and it is what you would read to rebuild from nothing. Keep it, install the reference CLI so it is not hostage to one vendor, and then choose the isolation layer for each consumer separately.

Keep reading

Related posts

  • PandaStack vs GitHub Codespaces

    Both give you a computer in the cloud with your repo in it. One is optimised for a person with an editor; the other for a program spawning hundreds of VMs an hour. The tell is whether a `for` loop is creating them.

  • Top 12 Ephemeral Development Environment Platforms in 2026: The Cost of the Second One

    The first ephemeral environment is a demo and every platform wins it. The second one is the product. Twelve platforms graded on idle cost, teardown semantics, whether uncommitted work survives a reap, and whether the isolation boundary is a kernel or a polite suggestion.

  • Top 7 Ephemeral Development Environment Platforms in 2026

    An environment is ephemeral when it is created from a definition, nobody is sad when it dies, and the 400th costs the same as the 4th. Most "cloud dev environments" fail at least one of those tests.

  • PandaStack vs Azure Container Apps: an honest head-to-head

    These two products look adjacent and are actually answering different questions. Azure Container Apps is a superb way to run your own microservices inside an Azure estate. PandaStack is a way to run somebody else's code without lying to yourself about the boundary. Here is the honest split.

  • Notebooks in Production: Parameterised Runs in Disposable microVMs

    The quarterly board metric comes out of cell 34, which must be run in order, by Dmitri, on his laptop. The notebook is not the problem. The laptop is. Papermill turns the notebook into a batch artefact; a disposable microVM per run turns it into one you can trust.

More in Security & isolation · See PandaStack security

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.