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/cli 0.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:

HookWhenWorkspace mounted?Use for
onCreateCommandDuring creation/prebuildNo (prebuild)OS packages, global tools that can be cached in a prebuilt image
updateContentCommandAfter onCreate, on content updatesYesDependency installs that track lockfiles
postCreateCommandOnce, after creationYesProject setup that needs the source tree
postStartCommandEvery container startYesCache warming, service health checks, safe.directory
postAttachCommandEvery client attachYesEditor-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

  1. Move expensive, source-independent work into onCreateCommand so it can be baked into a prebuild.

    { "onCreateCommand": "sudo apt-get update && sudo apt-get install -y --no-install-recommends ripgrep" }
    
  2. Track lockfile-driven installs in updateContentCommand so they re-run when content changes.

    { "updateContentCommand": "npm ci --prefer-offline" }
    
  3. Do project bootstrapping once in postCreateCommand.

    { "postCreateCommand": "bash .devcontainer/setup.sh" }
    
  4. 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
    
  5. Reserve postStartCommand for 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 onCreateCommand layer 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 so updateContentCommand resolves from disk.
  • Keep hooks non-blocking where possible; a slow postAttachCommand delays 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

SymptomRoot CauseRemediation
postCreateCommand can’t find source filesLogic placed in onCreateCommand, which runs before workspace content in prebuildsMove source-dependent work to updateContentCommand or later
Second rebuild breaks the environmentNon-idempotent hook (appends to a file, re-seeds a DB)Add existence guards and sentinel files
Feature B missing tooling from Feature ANo declared installsAfter, undefined orderPin order via overrideFeatureInstallOrder
Slow reconnectsHeavy work in postAttachCommandMove one-time work to postCreateCommand
Hook runs as rootremoteUser unset, command inherits rootDeclare 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.