Feature & Lifecycle Hook Sequencing
Two ordering systems govern how a DevContainer reaches a ready state: the Feature install graph and the lifecycle hook sequence. Engineers lose hours when a postCreateCommand runs before a Feature has finished installing, or when a hook that assumes a mounted workspace runs during the prebuild phase where no workspace exists yet. This page makes both orders explicit and shows how to keep every command idempotent so reruns never corrupt state.
It expands the lifecycle section of the DevContainer Architecture & Core Tooling pillar and complements the devcontainer.json property reference, which lists each hook property.
Prerequisites
@devcontainers/cli0.58+ or VS Code Dev Containers extension- Docker Engine 24+ with BuildKit enabled (
DOCKER_BUILDKIT=1) - A base image that includes a non-root user (e.g.
vscode)
Architecture & Configuration Deep Dive
Feature install order
features is a map, not an array, so install order is not the order you wrote keys in. The runtime computes a dependency graph from each Feature’s installsAfter metadata, then installs in topological order. When two Features have no declared relationship, order is undefined — pin it yourself:
{
"features": {
"ghcr.io/devcontainers/features/common-utils:2": {},
"ghcr.io/devcontainers/features/node:1": { "version": "20" },
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
},
"overrideFeatureInstallOrder": [
"ghcr.io/devcontainers/features/common-utils",
"ghcr.io/devcontainers/features/node"
],
"remoteUser": "vscode"
}
overrideFeatureInstallOrder lists Feature IDs (without the version tag). Anything omitted installs after the listed Features, still in dependency order.
The lifecycle sequence
Hooks fire in exactly this order, and each has a distinct contract:
| Hook | When | Workspace mounted? | Use for |
|---|---|---|---|
onCreateCommand | During creation/prebuild | No (prebuild) | OS packages, global tools that can be cached in a prebuilt image |
updateContentCommand | After onCreate, on content updates | Yes | Dependency installs that track lockfiles |
postCreateCommand | Once, after creation | Yes | Project setup that needs the source tree |
postStartCommand | Every container start | Yes | Cache warming, service health checks, safe.directory |
postAttachCommand | Every client attach | Yes | Editor-specific bootstrapping |
The critical boundary is between onCreateCommand and the rest: during a Codespaces prebuild, onCreateCommand runs without the final workspace contents, so anything touching your source code must live in updateContentCommand or later.
Step-by-Step Implementation
-
Move expensive, source-independent work into
onCreateCommandso it can be baked into a prebuild.{ "onCreateCommand": "sudo apt-get update && sudo apt-get install -y --no-install-recommends ripgrep" } -
Track lockfile-driven installs in
updateContentCommandso they re-run when content changes.{ "updateContentCommand": "npm ci --prefer-offline" } -
Do project bootstrapping once in
postCreateCommand.{ "postCreateCommand": "bash .devcontainer/setup.sh" } -
Make every hook idempotent. Guard side effects so a restart is safe:
#!/usr/bin/env bash set -euo pipefail # Idempotent: only seed the database if it is empty if [ ! -f /workspace/.devcontainer/.seeded ]; then ./scripts/seed-db.sh touch /workspace/.devcontainer/.seeded fi -
Reserve
postStartCommandfor things that must run on every boot, such as marking the workspace safe for Git:{ "postStartCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}" }
Performance & Resource Optimization
- Prebuild the
onCreateCommandlayer so cold starts skip apt installs entirely — this is the single biggest startup win, and it underpins Codespaces prebuild cost comparisons. - Use
--prefer-offline(npm) or a named-volume package cache soupdateContentCommandresolves from disk. - Keep hooks non-blocking where possible; a slow
postAttachCommanddelays every reconnect.
Validation & Testing
# Observe hook order in the build log
devcontainer up --workspace-folder . --remove-existing-container 2>&1 | grep -iE "onCreate|updateContent|postCreate|postStart"
# Confirm idempotency: run twice, expect identical end state and no errors
devcontainer exec --workspace-folder . bash .devcontainer/setup.sh
devcontainer exec --workspace-folder . bash .devcontainer/setup.sh
Common Pitfalls
| Symptom | Root Cause | Remediation |
|---|---|---|
postCreateCommand can’t find source files | Logic placed in onCreateCommand, which runs before workspace content in prebuilds | Move source-dependent work to updateContentCommand or later |
| Second rebuild breaks the environment | Non-idempotent hook (appends to a file, re-seeds a DB) | Add existence guards and sentinel files |
| Feature B missing tooling from Feature A | No declared installsAfter, undefined order | Pin order via overrideFeatureInstallOrder |
| Slow reconnects | Heavy work in postAttachCommand | Move one-time work to postCreateCommand |
| Hook runs as root | remoteUser unset, command inherits root | Declare remoteUser; prefix privileged steps with sudo explicitly |
Conclusion
The single most important principle: match each command to the phase whose guarantees it actually needs, and make it safe to run again. Source-dependent work never belongs in onCreateCommand; anything in postStartCommand must tolerate repeated execution.
FAQ
Why does my postCreateCommand run as root even though I set remoteUser?
Confirm the user exists in the base image. If remoteUser names a user the image never created, the runtime falls back to root. Add the user in your Dockerfile or use a base image that ships vscode.
Does updateContentCommand run on every rebuild?
It runs after onCreateCommand during creation and again whenever the orchestrator detects content updates (notably during Codespaces prebuild refreshes). Treat it as “runs more than once” and keep it idempotent.
How do I force one Feature to install before another?
List both in overrideFeatureInstallOrder in the order you need. The runtime honours that list before falling back to the dependency graph.
Related
- DevContainer Architecture & Core Tooling — the parent pillar this sequencing belongs to.
- devcontainer.json Property Reference — every hook property in one table.
- Understanding the DevContainer Specification — the schema that defines these hooks.
- Docker Compose Integration for Multi-Service Apps — how hooks interact with multi-service startup.