Feature & Lifecycle Hook Sequencing

Features and lifecycle hooks are the two mechanisms the DevContainer spec gives you for turning a bare base image into a fully-provisioned environment — and getting their ordering right is the difference between a container that hydrates cleanly and one that races itself into corruption. This guide is the detailed contract for engineers who need deterministic tool injection and reliable, idempotent setup across rebuilds. It expands the overview in the architecture guide into the actual resolution rules.

The mental model is a pipeline: Features install during the image/compose build in a resolved dependency order, and then the lifecycle commands run in a fixed sequence from creation through attach. Features answer "what tools exist"; hooks answer "what setup runs, and when." Confusing the two — installing a compiler in a hook, or running project setup in a Feature — is the root of most sequencing bugs.

Prerequisites

You need a base image that supports the Features mechanism (the mcr.microsoft.com/devcontainers/* images do, as do most modern bases), the OCI references for the Features you want with explicit versions, a clear idea of any install-order constraints, and a mapping of your setup steps to the correct lifecycle command. Confirm your engine can pull from the Feature registry (ghcr.io by default) and that any private Features are reachable.

The most valuable preparation is not a tool but a decision: for every piece of setup your environment needs, decide up front whether it is a Feature or a hook. That single classification prevents the majority of sequencing bugs before they can occur, because the two mechanisms have completely different execution models. A Feature installs durable tooling during the build, in a resolved order, cached as a layer; a hook runs project setup at a specific lifecycle moment, uncached, re-run on every create. Sketching this mapping — this runtime is a Feature, that dependency install is a postCreateCommand, this git-hook wiring is also a postCreateCommand, that database seed is a postStartCommand — turns the rest of the configuration into filling in a known structure rather than discovering it by trial and error.

Private Features deserve a moment of setup attention because they fail in a way that is easy to misdiagnose. A Feature referenced by an OCI path your engine cannot authenticate to will fail during the build with a pull error that can look like a network problem rather than a permissions one. If your team publishes internal Features to a private registry, confirm the build environment — both developer machines and CI runners — has the credentials to pull them before you depend on one, and pin them by version exactly as you would a public Feature. A private Feature is still a supply-chain artifact, and it deserves the same pinning and access discipline as any base image.

  • Base image compatible with the Features install mechanism.
  • Feature references pinned: ghcr.io/devcontainers/features/node:1, not a floating latest.
  • Known ordering constraints expressed with overrideFeatureInstallOrder.
  • Each setup step assigned to onCreate, postCreate, postStart, or postAttach.

Setup prerequisitesFeature composition needs a compatible base, pinned OCI references, an explicit order, and hooks mapped to state.Base imagesupports FeaturesFeature refsOCI paths + versionsInstall orderdeclared if it mattersHooksmapped to state

Feature Composition & the Install Graph

Features are resolved into a graph before anything installs. Each Feature can declare dependsOn (hard dependencies pulled in automatically) and installsAfter (soft ordering hints), and the tooling topologically sorts them into a concrete install order. When the automatic order is wrong for your case — a Feature that must run before another despite no declared edge — overrideFeatureInstallOrder lets you state the sequence explicitly and wins over the inferred order.

It helps to think of a Feature as a small, versioned, reusable install unit with a declared contract, because that framing explains both its power and its constraints. The power is reuse: a tool installed correctly once as a Feature can be pulled into any devcontainer by a single OCI reference, ordered against other Features, and configured through options — far better than copying an install script between repositories. The constraint is that a Feature runs during the build, as root, before your source exists, so it is strictly the wrong place for anything that depends on your project's files. A Feature that tried to read your lockfile or write into your workspace would find neither, because both arrive later, at mount time. This is the same source-independence boundary that separates onCreateCommand from postCreateCommand, expressed at the composition layer.

Feature install graphRequested Features are resolved into a dependency-ordered install sequence, then their metadata is merged.Requested Featuresthe features object in devcontainer.jsonDependency resolutiondependsOn + installsAfter edgesResolved install orderoverrideFeatureInstallOrder winsSequential installeach Feature's install.sh runsMerged metadataenv, entrypoints, customizations

Each Feature contributes more than an install script: it can inject environment variables, container entrypoints, and customizations (such as recommended extensions) that are merged into the final configuration. Because Features run during the build, their results are layer-cached, so pinning them by version or digest keeps both the behaviour and the cache reproducible. Treat a Feature reference exactly like a base image reference — pin it, and apply the same registry best practices you use for images.

The dependency resolution is worth understanding in a little more depth because it is where "it worked yesterday" surprises originate. dependsOn expresses a hard requirement — Feature A cannot function without Feature B, so the tooling pulls B in and installs it first even if you did not list it. installsAfter expresses a soft preference — if both A and B are present, A should come after B, but B is not pulled in automatically. The topological sort combines all these edges from every Feature into one global order. The subtlety is that adding or removing a Feature can change that global order, so a Feature that happened to install after another yesterday might install before it today simply because a third Feature altered the graph. When install order actually matters to correctness, do not rely on the emergent topological result — state it explicitly with overrideFeatureInstallOrder, which is stable regardless of what else is in the graph.

That Feature results are layer-cached has a consequence worth exploiting: a Feature is the fast place to put durable tooling, precisely because its install runs once and is reused on every subsequent build until the Feature version changes. This is the opposite of putting the same tool install in a postCreateCommand, which re-runs on every create and is never cached. So the classification decision — Feature versus hook — is not only about correctness and ordering; it is also a performance decision. Durable, source-independent tooling belongs in a Feature both because it will be ordered correctly and because it will be cached, whereas a hook that installs the same tool pays the install cost on every single create. When a devcontainer feels slow to create, one of the first things to check is whether tooling that could be a cached Feature is instead being reinstalled by a hook.

Lifecycle Command Sequencing

After Features install, the lifecycle commands run in their fixed order. The decisive property of each is its relationship to the workspace mount and to container restarts. onCreateCommand runs once, pre-mount — bake work here that a prebuild should capture. updateContentCommand runs on content updates and is re-executed by prebuilds. postCreateCommand runs post-mount — the correct and only reliable place for npm ci, poetry install, go mod download, and git-hook installation. postStartCommand runs on every start (seed a database, start a watcher); postAttachCommand runs per client attach.

Build-to-attach sequenceFeatures install during build; the five lifecycle commands then run from create through attach.buildimage + Featureslayers cachedonCreateonCreateCommandpre-mount setupupdateupdateContentCommandprebuild contentpostCreatepostCreateCommandinstall depspostStartpostStartCommandseed servicespostAttachpostAttachCommandper attach

A command can take three shapes, and knowing them prevents a lot of shell gymnastics: a string (run in a shell), an array (exec form, no shell parsing), or an object whose named entries run in parallel. Use the object form to parallelize independent setup — installing dependencies while warming a cache — and the array form when you need to avoid shell quoting pitfalls. Whatever the shape, mount-dependent work belongs after postCreate.

The distinction between onCreateCommand and postCreateCommand is the one that repays the most careful attention, because it is both the most common source of bugs and the most common source of missed optimization. onCreateCommand runs after the image is built but before your workspace is bind-mounted, so it sees the tools the image and Features provide but not a single line of your source. postCreateCommand runs after the mount, so it is the first hook that can read your package.json, write into .git/hooks, or run your project's build. The bug is putting mount-dependent work in onCreate — a npm ci there installs against a package.json that does not exist yet, and a husky install there has no .git to write to. The optimization is the mirror image: any setup that does not need your source belongs in onCreate, because that stage can be baked into a prebuild while postCreate cannot.

updateContentCommand is the hook most people never touch and occasionally need. It runs on content refreshes and, importantly, is re-executed by Codespaces prebuilds when they update their cached content. If you have setup that should run both at creation and whenever the prebuild refreshes its content — regenerating something derived from the source, for instance — updateContentCommand is where it belongs. For most projects the three hooks that matter are onCreate (cacheable, pre-mount), postCreate (source-dependent, post-mount), and postStart (every start); knowing those three cold, and reaching for updateContent and postAttach only when their specific timing is needed, covers the vast majority of real configurations.

Performance & Resource Optimization

The sequencing model is also a performance model. Because onCreateCommand runs before the mount and its result is cacheable, it is the ideal stage to bake into a prebuilt image — GitHub Codespaces prebuilds and CI-warmed images both exploit this. Move as much deterministic, source-independent setup as possible into onCreate so a prebuild captures it, and keep only source-dependent work in postCreate.

Effect of prebuilding hook stagesBaking the onCreate stage into a prebuilt image and warming caches slashes rebuild time.Rebuild, no prebuild88sPrebuilt onCreate stage26sPrebuilt + warm caches10sapproximate rebuild-to-ready time

The chart shows the compounding effect: prebuilding the onCreate stage and warming the package caches turns a slow rebuild into a fast attach. Pair this with the caching guidance in the architecture guide and per-language cache volumes from the language configurations guide for the full effect.

The lever you control for prebuild performance is how much work you can honestly move into onCreateCommand. Every command there is captured by a prebuilt image and paid for once, centrally, rather than on every developer's create. So the optimization is a refactoring exercise: look at what currently lives in postCreateCommand and ask, for each step, whether it truly depends on the source. Installing global CLIs, warming a base toolchain, downloading a large fixed dataset — none of these need your specific commit, so they can migrate up to onCreate and be prebuilt. Only the genuinely source-dependent step, installing dependencies from this commit's lockfile, has to remain in postCreate. The more you can shift upward, the smaller the per-create cost becomes, and the more a prebuild resembles an instant attach.

There is a second, independent performance lever hiding in the command shape. Because the object form of a lifecycle command runs its named entries in parallel, independent setup steps that currently run one after another can be made concurrent. A postCreateCommand that installs Node dependencies, then Python dependencies, then downloads a tool runs those three serially as a string; expressed as an object with three named entries, they run at once, and the hook takes as long as the slowest single step rather than the sum. For environments with several independent install steps this can meaningfully shorten create time at zero cost to correctness, since the steps were independent to begin with.

Validation & Testing

Test Features and hooks the way you test code. devcontainer read-configuration confirms the Feature graph resolves; devcontainer up runs the full sequence and surfaces any hook failure; and devcontainer exec lets you assert the resulting state — the tool is installed, the dependency tree is present, the hook ran. Crucially, run the sequence twice in CI: a second devcontainer up --remove-existing-container re-runs every hook, and any non-idempotent hook will reveal itself as doubled state or an outright failure.

Assertions after the build are what turn "it seemed to work" into "it demonstrably works." A devcontainer exec that checks node --version, confirms a Feature-installed CLI is on the PATH, or runs the project's own test suite proves the provisioning produced the environment you intended, not merely a container that started. Building these assertions into CI means a Feature that silently stopped installing, or a hook that quietly failed, is caught by a red pipeline rather than by a developer who attaches and finds a tool missing. The Feature graph and the hook sequence are as much a part of your project as the code, and they deserve the same automated verification.

Idempotency checkAny state-mutating hook must be guarded so repeated runs converge to the same state.Does the hook mutate state (append,symlink, install)?YESGuard it: check-then-act or use anidempotent toolRe-running twice yields the same state?YESSafe under rebuild + reattachHook is idempotent

Idempotency is the property most worth asserting explicitly. Any hook that appends to a file, creates a symlink, or installs a package must be guarded — check-then-act, or use tools that are idempotent by design — so that the tenth run leaves the same state as the first. This is what makes rebuilds and reattaches safe.

The reason idempotency deserves an explicit test rather than a hopeful assumption is that non-idempotent hooks fail quietly and late. A hook that appends a line to a shell profile works perfectly the first time; the corruption only appears after the second or third rebuild, by which point the connection between the symptom (a doubled PATH, a duplicated alias block) and the cause (an unguarded append weeks ago) is easy to miss. Because the failure is delayed, it escapes the "it worked when I set it up" check that catches most problems. The double-up test in CI collapses that delay to zero: it runs the second create immediately, so a non-idempotent hook is caught the moment it is introduced rather than discovered by a frustrated developer much later.

Writing idempotent hooks is a small discipline with a few reliable patterns. For appends, guard with a presence check (grep -qxF 'line' file || echo 'line' >> file) so the line is added only once. For symlinks, use ln -sf, which replaces rather than duplicates. For installs, prefer tools that reconcile against a lockfile or declared state — npm ci, poetry install --sync, chezmoi apply, stow — over commands that blindly add. And when a hook's logic is more than a line or two, move it into a shell script the hook invokes, so it can be tested in isolation and reasoned about like any other code rather than buried in a JSON string. A hook you cannot confidently run twice is a latent corruption; a hook built from these patterns is safe by construction.

Common Pitfalls

The failures below split cleanly into a Feature concern (ordering, placement) and a hook concern (mount timing, idempotency); the triage below points at which.

The reason this two-way split is worth naming explicitly is that it makes diagnosis nearly mechanical. When something goes wrong with provisioning, the first question is not "what broke?" but "is this a Feature problem or a hook problem?" — and the answer is usually obvious from the symptom. A tool that is missing or installed in the wrong order is a Feature (composition) problem; a setup step that fails because it ran too early, or that corrupts state on rebuild, is a hook (sequencing) problem. Once you have placed the failure in one of the two families, the fix follows: Feature problems are solved by pinning, placement, or overrideFeatureInstallOrder; hook problems are solved by moving the work to the right lifecycle stage and making it idempotent. The pitfalls table below is really this same split, enumerated.

Feature-or-hook triageA triage path for deciding whether a step is a Feature or a lifecycle hook, and where it belongs.Is the tool durable and needed by everyrebuild?YESMake it a Feature or Dockerfile layerIs the setup source-dependent (lockfile,.git)?YESPut it in postCreateCommand, guardedRight mechanism, right stage, idempotent

SymptomRoot CauseRemediation
Feature installs in the wrong orderRelied on inferred order that resolved incorrectlySet overrideFeatureInstallOrder explicitly
Compiler missing at postCreateTool put in a hook instead of a Feature/imageInstall durable tooling via a Feature or the Dockerfile
Git hooks fail to installhusky install ran in onCreate before the mountMove it to postCreateCommand
Doubled PATH or duplicated config after rebuildNon-idempotent postCreate appends each runGuard the append or use an idempotent tool
Slow rebuilds despite few changesDeterministic setup left in postCreate, not prebuiltMove source-independent work into onCreateCommand

Conclusion

Keep the division sharp: Features install tooling once, declaratively, in a resolved order; hooks run project setup in a fixed sequence, idempotently, respecting the mount. When durable tools live in Features and image layers, and only source-dependent, idempotent setup lives in postCreate and later, rebuilds are fast and reattaches are safe. The order is not a suggestion — it is the invariant that keeps a hydrating workspace from racing itself.

If you internalize one thing from this guide, let it be the two questions that place any piece of setup correctly. First: does this need my source? If no, it is a Feature or an onCreateCommand — cached, pre-mount, prebuild-friendly. If yes, it is a postCreateCommand or later — after the mount, where your files exist. Second: does this need to run every start, or only once? Once-only creation work is postCreate; per-start work like seeding a database or starting a watcher is postStartCommand. Those two questions, asked of every setup step, resolve nearly every sequencing decision you will ever face, across every language and every project.

The payoff for getting the sequencing right is not merely that the environment provisions correctly the first time; it is that it keeps provisioning correctly forever, through every rebuild and every teammate's fresh create. A configuration where Features are pinned and ordered, cacheable work is prebuilt, and every hook is idempotent and correctly placed is one that a new hire clones and rebuilds into an identical environment on the first attempt, and one that a developer rebuilds freely without fear of accumulating corruption. That durability — reproducible not just once but indefinitely — is the whole reason the ordering rules exist, and it is what separates a devcontainer that merely works from one a team can rely on.

Features vs hooksFeatures install tooling declaratively; hooks run ordered, idempotent project setup around the mount.Features doInstall tools onceContribute env + entrypointsDeclare dependenciesPin by version/digestHooks doRun project setupSeed + start servicesStay idempotentRespect mount timing

FAQ

When should a setup step be a Feature versus a lifecycle hook? If it installs durable tooling that every rebuild needs — a language runtime, a CLI, a system package — make it a Feature (or a Dockerfile layer) so it is cached and ordered. If it is project-specific setup that depends on your source — installing dependencies from a lockfile, wiring git hooks, seeding a database — make it a lifecycle hook, and put mount-dependent work in postCreateCommand or later. The quick test is whether the step needs your source files to run: if it does, it cannot be a Feature, because Features run before your workspace is mounted.

How do I force one Feature to install before another? Prefer the Feature's own dependsOn/installsAfter metadata when you control it. When you don't, set overrideFeatureInstallOrder in devcontainer.json to list the Features in the exact order you need; it takes precedence over the inferred topological order and is the deterministic way to resolve ordering conflicts. Prefer it whenever order matters to correctness, because the inferred order can shift when you add or remove an unrelated Feature, whereas an explicit override stays stable regardless of what else is in the graph.

Why does my hook produce different results on a rebuild? Because it is not idempotent. Rebuilds and reattaches re-run hooks, so a command that appends to a file or installs without a guard accumulates state. Make every state-mutating hook check-then-act, or use tools designed to converge (chezmoi, package managers with lockfiles), so repeated runs leave identical state. The reliable way to catch non-idempotency before it reaches a developer is to run devcontainer up --remove-existing-container a second time in CI: the second create re-runs every hook against a fresh container, so any accumulation shows up immediately as doubled state or a failure.

Can lifecycle commands run steps in parallel? Yes — a lifecycle command can be an object whose named entries execute in parallel, rather than a single string that runs serially. This is the right tool when a hook has several independent steps, such as installing dependencies for two languages that do not depend on each other, or installing dependencies while warming a separate cache. Expressed as an object, the hook takes as long as its slowest entry instead of the sum of all of them, which can meaningfully shorten create time at no cost to correctness. Reserve the parallel form for genuinely independent work, since steps that depend on one another still need the ordering a serial string gives them.

Do Features run before or after my lifecycle hooks? Before. Features install during the image build, which completes before any lifecycle command runs, so by the time onCreateCommand fires every Feature's tooling is already present. This ordering is why durable tools belong in Features rather than hooks: a Feature is guaranteed to be installed and ordered before your hooks execute, whereas a tool installed by a hook is only available to hooks that run later. If a postCreateCommand needs a compiler, that compiler should come from a Feature or the base image, not from an earlier hook.