coursera_2026_06
SitePoint Premium
Stay Relevant and Grow Your Career in Tech
  • Premium Results
  • Publish articles on SitePoint
  • Daily curated jobs
  • Learning Paths
  • Discounts to dev tools
Start Free Trial

7 Day Free Trial. Cancel Anytime.

How to Configure GitHub Codespaces Prebuilds

  1. Move heavy dependency installs (e.g., npm ci) into onCreateCommand so they run during the prebuild.
  2. Place compilation steps like npm run build in updateContentCommand to prebake build artifacts.
  3. Reserve postCreateCommand for secret-dependent or user-specific setup only.
  4. Enable prebuilds under Settings → Codespaces → Prebuilds for your target branch.
  5. Set the trigger to "every push" for main and "configuration change only" for feature branches.
  6. Select prebuild regions matching your team's geographic distribution.
  7. Add a devcontainers/ci validation job to your PR workflow filtered to .devcontainer/** paths.
  8. Verify prebuild success in the GitHub UI and confirm new codespaces show the "Prebuild ready" label.

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.

Table of Contents

The 8-Minute Problem

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.

How Codespaces Prebuilds Actually Work

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.

What Gets Baked Into the Prebuild

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

  • OS-level packages installed via features or a custom Dockerfile
  • Dependencies installed by onCreateCommand and updateContentCommand
  • Build artifacts generated during those commands
  • VS Code extensions declared in customizations.vscode.extensions
  • The full file system state at the end of the lifecycle sequence

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.

The Baseline devcontainer.json (Before Optimization)

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 devcontainer.json for Prebuilds

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.

Why Lifecycle Command Separation 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:

Hook During Prebuild First Connect (no prebuild) First Connect (with prebuild) Reconnect Use For
onCreateCommand Yes Yes No (already baked in) No Heavy installs: npm ci, database seeds
updateContentCommand Yes Yes Runs if source changed since prebuild No Compilation, code generation
postCreateCommand No Yes Yes No User-specific setup, secret-dependent steps
postAttachCommand No Yes Yes Yes Lightweight tasks: git fetch, env checks

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 vs npm install in Prebuilds

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.

Configuring Prebuild Triggers in Repository Settings

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

  1. Select the branch to prebuild.
  2. Choose the devcontainer configuration file (relevant for multi-devcontainer repos, where each config can have its own prebuild).
  3. Select trigger conditions: prebuilds can fire on pushes to the branch, on configuration changes only, or on a combination.
  4. Choose the regions where prebuilds should be available. If your team is distributed across the US and Europe, prebuild in both regions. A developer in Frankfurt creating a codespace from a prebuild stored only in US East will either fall back to standard creation or eat cross-region latency.
  5. Set retention: how many prebuild versions to keep.

Recommended Trigger Strategy

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.

Integrating Prebuilds with GitHub Actions CI/CD

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.

Path Filtering for Efficient Prebuild Triggers

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.

Measuring the Impact: Before and After

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:

Step Without Prebuild With Prebuild
Container image pull ~45s ~15s (prebuilt snapshot)
npm ci ~180s 0s (pre-installed)
npm run build ~120s 0s (pre-built)
VS Code extensions ~90s ~10s
Total to usable ~7 min 15s ~25s

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.

Cost Management and Gotchas

Prebuild Compute Costs

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.

Common Pitfalls

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.

Quick-Reference Checklist

  1. Separate lifecycle commands: heavy installs in onCreateCommand, compilation in updateContentCommand, secret-dependent steps in postCreateCommand, lightweight recurring tasks in postAttachCommand.
  2. Use npm ci --prefer-offline --no-audit (or equivalent deterministic install) in onCreateCommand.
  3. Enable prebuilds on main with every-push triggers.
  4. Enable prebuilds on feature branches with configuration-change-only triggers.
  5. Select prebuild regions matching your team's geographic distribution.
  6. Add a devcontainers/ci validation job to your PR workflow, filtered to relevant paths.
  7. Set prebuild retention to 2 versions.
  8. Monitor Codespaces compute and storage usage monthly (not Actions minutes).
  9. Never put secret-dependent commands in onCreateCommand or updateContentCommand.
  10. Verify prebuild success in the GitHub UI before assuming the team benefits.

Compounding Returns

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.

SitePoint TeamSitePoint Team

Sharing our passion for building incredible internet things.

© 2000 – 2026 SitePoint Pty. Ltd.
This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.