Pre-commit Hook Configuration for Containerized Workflows

Git hooks catch problems before they enter history, but wiring them inside a devcontainer trips up almost everyone the same way: the hook install runs before the .git directory is mounted, so it silently fails. This guide, under the customization guide, gets the timing right and makes commit-time checks mirror CI, so "it passed pre-commit" actually means something.

The single most important fact is when to install. Hook frameworks write into .git/hooks, and that directory only exists after your workspace is bind-mounted — which happens after onCreateCommand and before postCreateCommand. So hook installation belongs in postCreateCommand, always.

Prerequisites

You need a hook framework (the pre-commit tool or Husky), the workspace .git mounted, a postCreateCommand that installs the hooks, and pinned hook revisions so the checks are reproducible.

  • pre-commit (Python) or Husky (Node) available in the image or as a dev dependency.
  • The workspace bind-mounted so .git is present at postCreate time.
  • postCreateCommand running the framework's install step.
  • Hook versions/revisions pinned in the framework config.

Hook prerequisitesYou need a hook framework, the mounted .git, a postCreate install, and pinned hook revisions.Hook frameworkpre-commit or huskyMounted .gitworkspace presentpostCreateinstallhooks wiredPinned hooksrevs fixed

The mount-timing prerequisite is subtle enough to deserve unpacking before the mechanics, because it is the reason nearly every first attempt fails. A devcontainer's lifecycle has a specific order: the image builds, then onCreateCommand runs, then the workspace is bind-mounted into the container, then postCreateCommand runs. The critical detail is that .git lives in your workspace, so it does not exist inside the container until the bind mount happens — which is after onCreateCommand and before postCreateCommand. A hook framework's install step writes scripts into .git/hooks, so running it in onCreateCommand means writing into a .git that is either absent (the install errors) or about to be shadowed by the mount (the writes vanish). This single ordering fact dictates that hook installation must live in postCreateCommand, and understanding why — that .git arrives with the mount — is what keeps you from "fixing" a broken install by moving it to the wrong place.

The framework-availability prerequisite has a build-time-versus-runtime distinction that mirrors the mount-timing one. The hook framework — the pre-commit binary or Husky — should be present at build time, installed via a Feature or as a dev dependency, so it is baked into the image and guaranteed available. The hook wiring — writing into .git/hooks — is a runtime step that depends on the mounted workspace. Conflating these is a common mistake: teams try to do everything at build time and hit the missing-.git wall, or do everything at postCreate and pay to reinstall the framework on every rebuild. The clean split is framework-in-the-image (build time), hooks-wired-at-postCreate (runtime), each placed where its dependencies actually exist.

The pinning prerequisite is what makes commit-time and CI verdicts agree, and it is easy to underweight. A hook framework config that references a hook repository without a pinned revision installs whatever version is current when the install runs, which means two developers who set up on different days — or a developer and CI — can run different versions of the same linter with different default rules. The result is the maddening "passes locally, fails in CI on the identical check" divergence. Pinning every hook by an exact revision turns the hook set into a locked dependency, so the same versions run everywhere and a bump becomes a deliberate, reviewable change rather than silent drift. Reproducibility of the checks themselves is a prerequisite for the whole "it passed pre-commit means something" promise.

Architecture & Configuration Deep Dive

The wiring is a short stack. The framework config (.pre-commit-config.yaml or Husky's setup) declares which checks run. The install step writes the hook scripts into .git/hooks, and it must run once the mount exists. On commit, the hooks execute against staged files. And the same checks are mirrored in CI, so a developer who bypasses a local hook still gets caught.

Hook wiring layersThe framework config installs into .git/hooks at postCreate and mirrors into CI.Framework config.pre-commit-config.yaml / huskyInstall into .git/hooksruns in postCreateCommandStaged-file runhooks execute on commitCI mirrorsame hooks run in pipeline

The mount-timing constraint is the whole architecture. onCreateCommand runs before the bind mount, so .git is absent and a hook install there either errors or writes into a directory that gets replaced by the mount. postCreateCommand runs after the mount, so .git/hooks is real. The Husky-specific version of this is detailed in running pre-commit hooks on postCreateCommand with husky.

The four-layer stack is worth reading as a defense-in-depth argument, because each layer answers a different way the previous one can be undermined. The framework config declares the checks, but a declaration does nothing until it is wired — so the install step exists to make the declaration real by writing into .git/hooks. The install makes hooks fire on commit, but a developer can bypass a local hook with --no-verify, and a new contributor may not have run the install at all — so the CI mirror exists as the enforced backstop that catches what the local hook missed. Read top to bottom, the stack is not four independent features but a chain where each layer covers the failure mode of the one above it, which is why omitting the CI mirror in particular guts the guarantee: local hooks become advisory rather than enforced.

The relationship between the local hook and the CI mirror is the most important architectural idea here, and it is a division of labor rather than redundancy. The local hook's job is speed of feedback — it runs in seconds on staged files so a developer learns about a lint or format problem before the commit is even finished, when fixing it is trivial. The CI mirror's job is enforcement — it runs the identical pinned checks on the server where no one can bypass them, so a problem cannot reach the protected branch regardless of whether the author ran the hook. They are the same checks in two positions: one optimized for fast, friendly, skippable feedback; the other for unskippable guarantee. Designing them as one config run in two places (rather than two separately-maintained check sets) is what keeps their verdicts identical.

That two-positions-one-config principle is why pinning matters so much at the architectural level, not just the reproducibility level. If the local hook and the CI mirror could drift to different versions, the whole "feedback and enforcement are the same check" model breaks — a developer could pass the local hook and fail CI on a rule the local version did not have, which trains people to distrust and bypass the local hook entirely. Pinning the hook revisions in the shared config, consumed identically by the local install and the CI run, is the mechanism that guarantees the two positions really are running the same check. The architecture only delivers "it passed pre-commit means it will pass CI" if both positions are locked to the same versions.

Step-by-Step Implementation

Install the framework at build time (Feature or dev dependency), then install the hooks in postCreateCommand after the mount, and pin the hook revisions so checks are reproducible.

Hook lifecycleInstall the framework at build, wire hooks at postCreate once .git is mounted, then run on commit and in CI.buildimage + Featuresframework installedmountedpostCreateCommand.git present ->install hookscommithooks fireon staged filespushCI mirrorsame checks run

{
  "name": "Pre-commit Hooks",
  "image": "mcr.microsoft.com/devcontainers/base:ubuntu@sha256:PINNED",
  "features": {
    "ghcr.io/devcontainers/features/node:1": { "version": "20" },
    "ghcr.io/devcontainers/features/python:1": { "version": "3.12" }
  },
  "postCreateCommand": "pip install pre-commit && pre-commit install --install-hooks",
  "remoteUser": "vscode"
}
# .pre-commit-config.yaml — pin every hook by rev
repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.6.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.5.0
    hooks:
      - id: ruff

A polyglot repository with hooks for several languages is covered in configuring pre-commit in a multi-language repo.

The postCreateCommand in the example does two things in sequence, and the order and idempotency both matter. It installs the framework (pip install pre-commit) and then wires the hooks (pre-commit install --install-hooks), with --install-hooks pre-fetching each hook's environment so the first commit is not slowed by downloading them. Because postCreateCommand runs on every create — including rebuilds — the command must be safe to run repeatedly; both pip install (which no-ops if already satisfied) and pre-commit install (which overwrites the hook scripts harmlessly) are idempotent, so re-running them on a rebuild is fine. Writing lifecycle commands to be idempotent is a general discipline the sequencing guide emphasizes, and it matters here because a postCreateCommand that errored or duplicated work on the second run would make rebuilds fragile.

Pinning every hook by rev in the framework config is the step that closes the local-versus-CI gap, and the example shows the shape: each repository entry carries an explicit rev: tag rather than a floating branch. Treat those revisions exactly like lockfile entries — bumped deliberately in a reviewed commit, never floating. The payoff is that pre-commit run in a developer's container and pre-commit run in CI install and execute byte-identical hook versions, so a check that passes locally passes in CI for the same reason. When you do update a hook, doing it as an explicit rev bump gives you a bisectable history: if a check starts behaving differently, the config diff shows exactly which hook moved and when, rather than leaving you to guess whether an upstream floated underneath you.

A multi-language repository stresses all of this further, because each language's hooks bring their own environment and their own install cost. The framework isolates each hook in its own environment, which is what lets a Python ruff hook and a JavaScript ESLint hook coexist without conflicting, but it also means more environments to build and cache. The linked multi-language how-to covers structuring such a config, and the key performance implication — that caching the framework's environment directory becomes more valuable the more languages are involved — is the same principle the next section develops. The architecture scales to polyglot repos cleanly; it just makes the environment cache proportionally more important.

Performance & Resource Optimization

Hooks run on every commit, so they must be fast. Running against all files is slow; the pre-commit framework runs hooks against staged files by default, and it caches each hook's environment so repeated commits reuse it.

Hook run time by scopeRunning hooks on staged files and caching their environments keeps commits fast.Run hooks on all files26sRun on staged files4sCached hook envs2sillustrative pre-commit run time

Cache the framework's environment directory (~/.cache/pre-commit) on a named volume so hook environments survive rebuilds, and keep heavy checks (full type-checking, whole-repo linting) in CI rather than the commit path. The goal is a commit hook fast enough that developers never reach for --no-verify.

The staged-files default is the single most important performance property, and understanding why it works keeps you from accidentally defeating it. pre-commit runs each hook only against the files staged for the current commit, not the whole repository, so a commit touching three files runs the linter on three files rather than thousands. This is what makes the commit-time run fast enough to be tolerable on every commit. The failure mode is configuring a hook to always run against all files (some hooks offer this), which turns a two-second commit into a full-repo lint and quickly trains developers to reach for --no-verify. Reserve whole-repo runs for CI, where the latency is acceptable, and let the commit path stay scoped to what actually changed.

Environment caching is the second lever, and it is where the named volume earns its place. Each hook runs in an isolated environment the framework builds on first use — a virtualenv, a node environment — and rebuilding those on every container rebuild would make the first post-rebuild commit painfully slow. Mounting the framework's cache directory (~/.cache/pre-commit) on a named volume lets those environments survive rebuilds, so the cost of building them is paid once rather than every time the container is recreated. This is the same caching discipline applied to language dependencies and extensions, pointed at the hook framework's store; the linked caching how-to details it, and the payoff grows with the number of hooks and languages involved.

Validation & Testing

Validate that hooks install after the mount (not before), that they actually fire on commit, and that CI runs the same pinned checks. A quick way to confirm: make a trivial staged change and commit, and watch the hooks run.

Hook validationConfirm hooks install post-mount, run on commit, and mirror into CI with pinned revisions.Do hooks install after the .git mount?NOInstall moved to onCreate by mistakeDo the same checks run in CI?YESHook revisions pinnedCommit-time and CI checks agree

# Confirm hooks are installed and run against staged files
pre-commit run --all-files     # one-off full run to validate config
git commit -m "test" --dry-run # hooks fire on staged files

The most valuable validation is a real commit, because it exercises the whole chain — install timing, wiring, and execution — in one action. Make a trivial staged change and commit; the hooks should fire, run against just that change, and either pass or block the commit. If nothing happens, the hooks were installed but not wired (the framework is present but pre-commit install never ran, or ran before the mount), and the commit sailed through unchecked. Because an unwired hook fails silently — commits succeed, they just skip the checks — a deliberate test commit is the only reliable way to confirm the hooks are actually active rather than merely configured. Running pre-commit run --all-files once additionally validates that the config itself is sound, separate from the git integration.

The CI-parity validation is what proves the "commit-time and CI agree" promise rather than assuming it. Confirm that CI runs the identical pinned config — ideally by invoking the same pre-commit run against the same .pre-commit-config.yaml — so there is one check definition consumed in both places rather than two that can drift. The tell that this has gone wrong is a check that passes locally and fails in CI (or vice versa) on the same file: that divergence means the two positions are running different versions or different configs. Validating that CI and the local hook share one pinned config, not two hand-synced ones, is what keeps a green local run a trustworthy predictor of a green pipeline.

Common Pitfalls

The failures below are dominated by the mount-timing mistake and by version drift. The triage below points at the cause.

Hook pitfall triageA triage path from a failing or divergent hook setup to a mount-safe, pinned one.Does hook install fail on create?YESRan before the .git mountDo CI and local checks differ?YESPin the hook revisionsConsistent, mount-safe hooks

SymptomRoot CauseRemediation
Hook install fails on container createRan in onCreate before .git mountedMove the install to postCreateCommand
Hooks not firing on commitFramework installed but hooks not wiredRun pre-commit install / husky install in postCreate
Local passes, CI fails the same checkHook revisions unpinned or differ from CIPin rev: values; run the same config in CI
Commits feel slowHooks run against the whole repoRely on staged-file runs; cache hook envs
Developers bypass hooks with --no-verifyHooks too slow or noisySpeed them up; enforce the check in CI too

The mount-timing pitfall deserves expansion because it is the one that catches everyone and it fails in a particularly confusing way. When the install runs in onCreateCommand, the symptom is not always a clean error — sometimes the install appears to succeed against a .git that then gets shadowed by the bind mount, so the hooks seem installed but never fire, which reads like a wiring bug rather than a timing one. The fix is always to move the install to postCreateCommand, but the deeper lesson is to recognize the signature: anything that touches the workspace or .git must run after the mount, so postCreateCommand is the home for all workspace-dependent setup. Hooks are the most common example, but the same rule governs anything that reads or writes files the developer brought with the repository.

The --no-verify bypass pitfall is a people problem masquerading as a technical one, and treating it as purely technical misses the fix. Developers reach for --no-verify when the hook is too slow, too noisy, or blocks them on something they consider spurious — so a rash of bypasses is a signal that the commit-time run needs to get faster or quieter, not that developers need scolding. Speed it up (staged files, cached environments, heavy checks moved to CI), reduce false positives, and the bypass rate falls on its own. Crucially, the CI mirror is what makes the bypass survivable: because CI enforces the same checks unskippably, a bypassed local hook is a lost opportunity for fast feedback, not a hole in the guarantee. The two-position design means you can afford to keep the local hook friendly precisely because CI is the one that cannot be skipped.

Conclusion

Install hooks after the mount, and enforce them beyond the commit. Put the hook install in postCreateCommand so .git/hooks exists, pin every hook revision so checks are reproducible, and mirror the same checks in CI so a bypassed local hook is still caught. Keep the commit-time run fast — staged files, cached environments — and developers will let it do its job instead of skipping it.

Install and enforceInstall hooks at postCreate after the mount, then enforce pinned checks in CI.Install correctlyIn postCreateCommandAfter .git mountIdempotentlyEnforcePin hook revsMirror in CIRun on staged files

Pulling the threads together, the reason this setup is worth getting exactly right is that a hook system is only valuable if developers trust it and cannot accidentally route around it. The mount-safe install ensures the hooks are actually wired; the pinning ensures every developer and CI run the same checks; the staged-file speed keeps the commit-time run fast enough that nobody wants to bypass it; and the CI mirror ensures that even a bypass or a missing local install cannot let a problem reach the protected branch. Remove any one of these and the system degrades: without mount-safe install the hooks silently do nothing, without pinning the checks drift, without speed developers bypass, and without CI the whole thing is advisory. The four together are what turn "we have pre-commit hooks" into "problems provably do not enter history."

Framed at the level of the environment, this is another instance of the site's recurring pattern: encode the good behavior once, in the shared, versioned configuration, so it is inherited rather than remembered. The .pre-commit-config.yaml and the postCreateCommand are committed, so a new teammate who opens the devcontainer gets working, correctly-timed, pinned hooks with zero manual setup, and the CI mirror consumes the same config so enforcement is automatic. Nobody has to remember to install hooks, match versions, or run the right checks — the environment does it. That is the difference between a hook system that works because everyone is disciplined and one that works because the configuration makes the correct setup the default.

FAQ

Why does my hook install fail during container creation? Because it ran in onCreateCommand, which executes before your workspace — and therefore .git — is bind-mounted. The hook framework has no .git/hooks directory to write into. Move the install to postCreateCommand, which runs after the mount exists, and it succeeds. The general rule this illustrates is that any setup touching the workspace or .git belongs in postCreateCommand, because that is the first lifecycle stage where the mounted workspace is actually present; onCreateCommand runs against the image before your files arrive, so it is the wrong home for anything that depends on repository contents. Keep image-level setup in the build or onCreate, and workspace-level setup in postCreate, and the timing errors disappear.

Do I still need CI checks if pre-commit runs the same hooks? Yes. Pre-commit hooks can be bypassed with --no-verify, and not every contributor has them installed. Mirror the same pinned checks in CI as the enforced backstop; the local hooks are for fast feedback, CI is for the guarantee. Pin the hook revisions so both run identical versions. Think of the two as one check in two positions — the local hook optimized for speed and friendliness (and therefore skippable), the CI run optimized for enforcement (and therefore unskippable). The local hook makes the good outcome cheap and immediate; CI makes it non-negotiable. Neither replaces the other, and dropping the CI mirror quietly turns the whole system from enforced into advisory.

How do I keep commits from getting slow? Rely on the framework's default of running against staged files rather than the whole repo, and cache each hook's environment (for example ~/.cache/pre-commit) on a named volume so rebuilds don't rebuild them. Push heavy, slow checks to CI so the commit path stays fast enough that nobody bypasses it. If bypasses still creep in, treat that as a signal the commit-time run needs to be faster or less noisy rather than a discipline problem — the local hook should feel like a helpful nudge, not a toll booth.

Why do my hooks install but never fire on commit? Almost always because the framework is present but the hooks were never wired into .git/hooks, or were wired before the bind mount shadowed them. Installing the pre-commit binary is not the same as running pre-commit install, which writes the actual hook scripts; and running that install in onCreateCommand writes into a .git that the mount then replaces. Confirm the wiring step (pre-commit install / husky install) runs in postCreateCommand, and validate with a real test commit — because an unwired hook fails silently, letting commits through unchecked with no error to tip you off.

Can I run pre-commit hooks in CI without git, just against changed files? Yes. In CI you typically run pre-commit run directly against a file set — either --all-files for a full sweep or a computed diff of the pull request's changed files — without relying on the git hook wiring at all. This is exactly the CI mirror: the same pinned config, executed by the same framework, but invoked as a pipeline step rather than triggered by a commit. Because it consumes the identical .pre-commit-config.yaml, the versions and rules match the local hook, which is what makes the local run a faithful predictor of the CI result.