GitHub Codespaces Prebuilds: Cutting Dev Environment Spin-Up from 8 Minutes to 30 Seconds

GitHub Codespaces Prebuilds: Cutting Dev Environment Spin-Up from 8 Minutes to 30 Seconds - Overview
GitHub Codespaces Prebuilds: Cutting Dev Environment Spin-Up from 8 Minutes to 30 Seconds

💡 Quick Summary

A team of 10 developers, each launching codespaces roughly three times per day, can easily burn four collective hours just waiting for environments to become usable. This guide walks through the full setup: how prebuilds work, a production-ready devc...

A team of 10 developers, each launching codespaces roughly three times per day, can easily burn four collective hours just waiting for environments to become usable. This guide walks through the full setup: how prebuilds work, a production-ready devcontainer.json, CI/CD integration via GitHub Actions, and the cost and billing gotchas that GitHub's own docs scatter across half a dozen pages.

Picture this: a new contributor opens a GitHub Codespaces environment for your project, and the terminal immediately starts churning through npm install. Three minutes pass. Five. Eight. By the time the devcontainer configuration finishes executing, they've lost context, checked their phone twice, and mentally moved on to something else. That's the default experience when your cloud development environment lacks prebuilds.

The math gets ugly fast. A team of 10 developers, each launching codespaces roughly three times per day, can easily burn four collective hours just waiting for environments to become usable. And "usable" here means VS Code fully connected, all lifecycle commands finished, extensions loaded. Not just the container starting. The moment a developer can actually type code and get linting feedback.

GitHub Codespaces prebuilds fix this by running the entire environment setup process ahead of time and storing the result. When a developer creates a codespace, they get the precomputed environment instead of building from scratch. In a Node.js monorepo I manage with roughly 1,200 dependencies and a TypeScript compilation step, we measured creation time dropping from just over 7 minutes to about 25 seconds after enabling prebuilds with proper lifecycle command separation.

In a Node.js monorepo I manage with roughly 1,200 dependencies and a TypeScript compilation step, we measured creation time dropping from just over 7 minutes to about 25 seconds after enabling prebuilds with proper lifecycle command separation.

This guide walks through the full setup: how prebuilds work, a production-ready devcontainer.json, CI/CD integration via GitHub Actions, and the cost and billing gotchas that GitHub's own docs scatter across half a dozen pages.

Without prebuilds, creating a codespace follows a predictable and slow sequence: GitHub pulls the container image specified in your devcontainer configuration, runs any Dockerfile build steps, executes lifecycle commands like onCreateCommand and updateContentCommand (which typically install dependencies and compile code), downloads VS Code extensions, and finally hands you a ready environment.

A prebuild changes this sequence by front-loading the expensive parts. When you configure a prebuild for a specific branch, GitHub runs the full devcontainer setup on that branch's code ahead of time, triggered by events you define (pushes, configuration changes, or a schedule). The result is a prebuilt environment that future codespace creations can use, skipping the install and build steps entirely.

The mental model: devcontainer.json defines what to build. The prebuild configuration in your repository settings defines when to build it. If no valid prebuild exists for the branch and region a developer requests, Codespaces falls back to standard creation. Prebuilds are an optimization layer, not a hard dependency.

The prebuilt environment includes everything that runs during the setup lifecycle:

One nuance worth knowing: extensions are included in the prebuild, but some extensions do additional initialization on first activation. Extensions with heavy post-install steps (language servers downloading binaries, for example) may still add a few seconds on first connect.

Here's what a typical, unoptimized devcontainer.json looks like for a Node.js/TypeScript project:

// Typical devcontainer.json — no prebuild awareness
{
  "name": "My Project",
  "image": "mcr.microsoft.com/devcontainers/typescript-node:20",
  "postCreateCommand": "npm install && npm run build",
  "customizations": {
    "vscode": {
      "extensions": [
        "dbaeumer.vscode-eslint",
        "esbenp.prettier-vscode"
      ]
    }
  }
}

This configuration stuffs all the heavy work into postCreateCommand. Every time someone creates a codespace without a prebuild, the container runs a full npm install (resolving the dependency tree from scratch, checking for vulnerabilities, writing node_modules) followed by a TypeScript build. On a mid-sized project, that's 3 to 5 minutes for dependency installation alone, plus another 1 to 2 minutes for compilation.

Here's the thing people miss: postCreateCommand does not run during the prebuild process. It runs only after a developer creates their codespace. So placing heavy install work here means that work is never prebaked. It happens at creation time regardless of whether prebuilds are enabled. This is exactly why lifecycle command separation matters so much. And postStartCommand, another lifecycle hook, fires after the container starts, including on rebuilds and restarts. Put heavy work in the wrong hook and you make restarts just as painful as first creation.

Here's the thing people miss: postCreateCommand does not run during the prebuild process. It runs only after a developer creates their codespace. So placing heavy install work here means that work is never prebaked.

The optimized version separates work across the correct lifecycle hooks, declares resource requirements explicitly, and configures port forwarding for common development scenarios:

{
  "name": "My Project — Prebuilt",
  "image": "mcr.microsoft.com/devcontainers/typescript-node:20",
  "features": {
    "ghcr.io/devcontainers/features/github-cli:1": {},
    "ghcr.io/devcontainers/features/docker-in-docker:2": {}
  },
  "onCreateCommand": "npm ci --prefer-offline --no-audit",
  "updateContentCommand": "npm run build",
  "postCreateCommand": "echo 'Environment ready.'",
  "postAttachCommand": "git fetch --all --prune",
  "customizations": {
    "vscode": {
      "extensions": [
        "dbaeumer.vscode-eslint",
        "esbenp.prettier-vscode",
        "GitHub.copilot",
        "eamodio.gitlens"
      ],
      "settings": {
        "editor.formatOnSave": true,
        "typescript.preferences.importModuleSpecifier": "relative"
      }
    }
  },
  "hostRequirements": {
    "cpus": 4,
    "memory": "8gb",
    "storage": "32gb"
  },
  "forwardPorts": [3000, 5432],
  "portsAttributes": {
    "3000": { "label": "App", "onAutoForward": "openBrowser" }
  }
}

Let me walk through what changed and why each decision matters.

The devcontainer spec defines several lifecycle hooks that run at distinct points. Getting this separation right is the single biggest factor in prebuild effectiveness:

Here's the key insight: onCreateCommand and updateContentCommand run during the prebuild process, so their results get baked into the prebuilt environment. postCreateCommand runs only after a developer creates their codespace, making it the right place for anything that needs access to Codespaces user secrets (private registry tokens, API keys). postAttachCommand runs every time someone connects, including reconnections, so it must stay lightweight.

There's also postStartCommand, which fires every time the codespace starts (including after a stop/restart cycle). Don't put heavy work there. It'll slow down every restart, not just initial creation.

This approach breaks down when your dependency installation requires authentication (e.g., a private npm registry). You can't put npm ci in onCreateCommand because prebuild environments don't have access to user-level Codespaces secrets. The workaround: configure a read-only registry token as a repository-level secret available to prebuild environments (repository-level Codespaces secrets can be made accessible to prebuilds via the prebuild configuration), or accept that the authenticated install step must live in postCreateCommand.

npm ci is the right choice for prebuilds because it's deterministic: it deletes node_modules entirely and installs exactly what's in package-lock.json, with no tree resolution or lockfile updates. This guarantees the prebuild produces the same result every time.

The --prefer-offline flag tells npm to use its local cache aggressively, which cuts network calls during the prebuild. --no-audit skips the vulnerability check during installation. This isn't ignoring security. It's deferring that check to your CI pipeline where it belongs, rather than adding 10 to 15 seconds to every prebuild run.

Prebuild configuration lives in your repository settings under Settings > Codespaces > Prebuilds. The setup process is straightforward:

For main: trigger on every push. This keeps the prebuild fresh for new contributors, PR reviewers, and anyone spinning up a quick environment to test something. The always-ready main prebuild is the highest-value configuration.

For feature branches: trigger on configuration change only. Rebuilding the prebuild on every feature branch commit burns compute for minimal benefit, since the devcontainer config rarely changes mid-feature.

Release branches deserve the same treatment as main: trigger on every push. Hotfix workflows demand fast environment creation, and release branches change infrequently enough that the compute cost is negligible.

If no valid prebuild exists for a given branch and region combination, Codespaces falls back to standard creation. Developers won't see an error; they'll just wait longer. Worth verifying that your prebuild actually succeeded before assuming the team is getting the speed benefit.

Validating your devcontainer configuration in CI prevents a broken config from shipping to main and producing a failed prebuild that nobody notices for days. The devcontainers/ci GitHub Action builds the devcontainer and optionally runs a command inside it, giving you a fast feedback loop on PRs.

name: Codespace Prebuild CI

on:
  pull_request:
    paths:
      - '.devcontainer/**'
      - 'package-lock.json'
  push:
    branches: [main]
    paths:
      - '.devcontainer/**'
      - 'package-lock.json'

jobs:
  validate-devcontainer:
    runs-on: ubuntu-latest
    if: github.event_name == 'pull_request'
    steps:
      - uses: actions/checkout@v4
      - name: Validate devcontainer.json
        uses: devcontainers/ci@v0.3
        with:
          runCmd: node --version && npm --version
          push: never

  notify-prebuild-ready:
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    steps:
      - uses: actions/checkout@v4
      - name: Confirm devcontainer builds
        uses: devcontainers/ci@v0.3
        with:
          runCmd: echo "Devcontainer validated. Repo prebuild trigger will fire."
          push: never

A note on programmatic prebuild triggering: the gh codespace CLI does not currently expose a prebuild create subcommand. Prebuild triggering happens automatically through the repository's prebuild configuration when matching events occur (pushes to the configured branch). If you need programmatic control, the GitHub REST API for Codespaces provides endpoints for managing prebuild configurations, but triggering an ad-hoc prebuild run works best through the built-in triggers rather than a CLI invocation. Check the devcontainers/ci action repository for the latest release tag; pinning to v0.3 is current as of this writing, but newer versions may be available.

The paths filter in the workflow above matters more than it looks. Without it, every README change or documentation update triggers a devcontainer validation job. The paths that actually affect your development environment are .devcontainer/**, package-lock.json (or yarn.lock, pnpm-lock.yaml), any Dockerfile referenced by the devcontainer, and language-specific dependency files like requirements.txt or Gemfile.lock.

We tested this configuration on a Node.js/TypeScript project with 1,247 dependencies, a 45-second TypeScript build, and four VS Code extensions. The codespace used a 4-core machine in the US West region. We measured by adding date +%s timestamps to each lifecycle hook:

These numbers are specific to this project and machine type. A Python project with a large pip install step or a Java project with a Gradle build will have different baselines. The ratios tend to hold, though: prebuild environments consistently deliver 80–95% reduction in creation time because dependency installation and compilation dominate the lifecycle. Your numbers will vary depending on project size, machine type, and region.

Prebuild environments consistently deliver 80–95% reduction in creation time because dependency installation and compilation dominate the lifecycle.

To check whether your codespace actually used a prebuild, look for the "Prebuild ready" label when creating a codespace from the GitHub repository page. The prebuild status is also visible in Settings > Codespaces > Prebuilds, where you can see the last successful prebuild for each configured branch and region. You can also check the creation log in the codespace terminal for indicators that lifecycle steps were skipped.

Prebuilds consume Codespaces compute time (billed per core-hour at the same rate as running codespaces) and Codespaces storage (billed per GB-month). This is separate from GitHub Actions minutes. I want to stress that because it's a common point of confusion. Each prebuild run consumes compute time equivalent to running the full environment setup on the configured machine type, plus storage for the retained prebuild artifacts. A monorepo with a 15-minute install step triggered on every push to main (say, 20 pushes per day) adds meaningful cost.

Recommendations: use path filters in your prebuild trigger configuration to avoid rebuilding when only non-environment files change. Limit prebuilt branches to two or three. Set retention to two prebuild versions unless you have a specific rollback need. Organizations can set spending limits and allowed machine types in their Codespaces policies to prevent cost surprises. GitHub Free and Pro personal accounts include a monthly allowance of Codespaces usage; prebuilds count against this allowance for personal repositories, while organization-owned repositories bill to the organization.

Secrets in prebuilds: Prebuild environments don't have access to user-level Codespaces secrets. If your onCreateCommand requires authentication (private npm registry, licensed tooling), it will fail during the prebuild. Move secret-dependent steps to postCreateCommand, which only runs when a developer creates the codespace and secrets are available. Repository-level secrets can be made available to prebuilds through the prebuild configuration if you need things like private registry access.

Stale prebuilds: If your prebuild triggers are too conservative (configuration change only on a branch that gets frequent dependency updates via Dependabot), developers may get an outdated prebuilt environment. Codespaces will run updateContentCommand to reconcile, which is faster than a full rebuild but still adds wait time. Consider using the "on every push" trigger for branches that receive automated dependency update PRs.

Storage bloat: Each retained prebuild version eats storage. For a project with a large node_modules directory and compiled output, two retained versions might consume 10 to 20 GB. Keep an eye on your Codespaces storage usage in the organization billing dashboard.

Region availability: Prebuilds are region-specific. If you configure prebuilds only for US West but a developer creates a codespace defaulting to Europe West, they won't benefit from the prebuild. Make sure your prebuild regions match where your team members actually create codespaces.

This configuration is a one-time investment. Once the devcontainer.json lifecycle commands are properly separated and the prebuild triggers are set, every developer on the team gets sub-30-second environment creation on every codespace launch, on every configured branch, in every configured region, without changing anything about how they work.

The best development environment is the one nobody has to think about. Prebuilds make the environment invisible.

For a team of 10, that's the difference between 4 hours of collective daily wait time and roughly 5 minutes. As the team grows, the savings compound: 20 developers, 50 developers, open source contributors you've never met. The best development environment is the one nobody has to think about. Prebuilds make the environment invisible. Confirm your prebuilds are succeeding in the GitHub UI, verify that new codespaces show the prebuilt label, and then stop thinking about it entirely.


Source: Original Publication