Running Pre-commit Hooks on postCreateCommand with Husky
Husky writes into .git, so installing it before your workspace is mounted silently fails — the single most common devcontainer hook bug. This page installs Husky at the right moment, in postCreateCommand after the .git mount exists, and makes the install idempotent so rebuilds stay clean.
This matters because Husky is not a self-contained linter; it is a thin wrapper that configures git's core.hooksPath to point at your .husky directory. That configuration is written into .git/config, and the hook scripts themselves live under a path git only recognises once the repository is present. In a plain checkout on your host the .git directory has always existed by the time you run npm install, so the coupling is invisible. Inside a devcontainer the repository arrives through a bind mount that the runtime attaches at a specific lifecycle moment, and any command that touches .git before that moment is operating against an empty or non-existent directory. The failure is quiet precisely because Husky treats a missing repository as a reason to skip its work rather than an error to shout about.
Reach for this pattern whenever your team runs lint-staged, formatters, or commit-message checks through Husky and you want every contributor's container to enforce them without a manual setup step. The mental model to hold is a strict ordering: the Node toolchain and the husky package are available at image-build time, the .git directory becomes real at mount time, and only after both are true can husky install wire the hooks path successfully. Put the install on the wrong side of that line and the container looks healthy while quietly committing unlinted code. Put it on the right side, make it idempotent, and every rebuild reconverges on the same working state.
Prerequisites
You need a Node project using Husky and the workspace mounted at postCreate time.
- A Node project with Husky as a dev dependency.
- The workspace bind-mounted so
.gitis present at postCreate. - A
prepare/install step Husky can run.
The prerequisite people most often get wrong is the mount itself, not the Husky dependency. It is easy to confirm that husky sits in devDependencies and assume the rest follows, but a devcontainer only has a usable .git directory at postCreate time if the workspace is bind-mounted rather than baked into the image. If your Dockerfile copies the source in during build, or if you are launching from a volume that was seeded before the repository existed, the .git git created on your host never reaches the container as a live directory. Check that the folder devcontainer opens is the repository root and that .git is present there, because Husky configures core.hooksPath relative to that root and will silently no-op if it is looking at the wrong place.
The second detail worth pinning down is the prepare lifecycle script. Modern Husky documentation suggests wiring husky install through npm's prepare script, which runs automatically after npm install. That is convenient on a host but a trap in a container, because prepare fires during the image build when .git is absent, so the automatic hook fails and npm ignores the failure. This page deliberately calls npx husky install explicitly in postCreateCommand so the install happens at a moment you control, after the mount, rather than at whatever moment the package manager decides.
Step-by-Step Implementation
- Install Husky in postCreateCommand, after the mount exists.
{
"features": { "ghcr.io/devcontainers/features/node:1": { "version": "20" } },
"postCreateCommand": "npm ci && npx husky install",
"remoteUser": "node"
}
The ordering inside postCreateCommand is deliberate: npm ci runs first so that the husky binary and every other dependency are present, and only then does npx husky install execute. Because postCreateCommand is the first lifecycle hook that runs after the workspace bind mount is attached, .git is guaranteed to exist by the time Husky tries to write core.hooksPath. Using npm ci rather than npm install means the dependency tree is resolved strictly from the committed package-lock.json, so the Husky version wired into the hooks is the same one every teammate gets. Setting remoteUser to node keeps the install running as an unprivileged user that owns the workspace, which avoids the root-owned files that later break git commit from inside the container.
- Define the hook Husky should run.
# .husky/pre-commit
npx lint-staged
This file is the actual hook git invokes, and it is intentionally minimal: a single npx lint-staged line that delegates the real work to the lint-staged configuration in your package.json. Keeping the hook thin means the container's job is only to make sure the script exists and is executable, while decisions about which files get linted and with which tools live in versioned config that changes review normally. The .husky/pre-commit file must be committed to the repository, because husky install wires the hooks path but does not author the hook scripts for you — if this file is missing, the path is configured yet nothing runs on commit. Using npx rather than a bare lint-staged call ensures the locally installed binary is found even when it is not on PATH.
- Make it idempotent — husky install is safe to re-run, and npm ci is deterministic.
Idempotency is what makes the whole approach durable across rebuilds. Every time a devcontainer is recreated — after a base image bump, a Rebuild Container action, or a fresh clone on a new machine — postCreateCommand runs again from scratch. Both commands are designed to converge rather than accumulate: npm ci deletes node_modules and reinstalls exactly what the lock file pins, and husky install simply re-writes core.hooksPath to the same value it held before. Because neither command appends state or duplicates entries, running them ten times leaves the repository in the identical condition running them once does. That property is why you can leave the install unconditionally in postCreateCommand instead of guarding it with a "has this already run" check that would itself become a source of drift.
- Verify the hook is wired and fires on commit.
test -x .husky/pre-commit && echo "hook installed"
The test -x check is more precise than simply asking whether the file exists: it confirms the executable bit is set, which is the property git actually requires before it will run the hook. A .husky/pre-commit file that lost its execute permission — a common casualty of copying across filesystems or of a checkout that does not preserve mode bits — will sit there looking correct while git silently declines to invoke it. Running this one-liner inside the container after postCreate finishes turns a hidden misconfiguration into a visible signal, and it is cheap enough to fold into a smoke test. For a stronger guarantee, follow it with an actual throwaway commit against a temporary file so you observe lint-staged firing end to end rather than just inferring it from the file's mode.
Common Pitfalls
Husky failures are almost always the mount-timing mistake or a missing dependency.
A quieter class of failure comes from ownership rather than timing. When a devcontainer runs postCreateCommand as one user but the workspace files were created or mounted as another — for example root-owned files landing in a bind mount, or a named volume seeded by an earlier root process — husky install may succeed while later git commit invocations refuse to run the hook because the process cannot write git's index or the hook script is owned by a foreign UID. This is why the configuration sets remoteUser to node and expects the mounted .git to be owned by that same user. If commits inside the container start reporting permission errors or git's "dubious ownership" warning, the fix is to align the workspace ownership with the user running the hooks, not to chase Husky itself.
The other trap that survives a correct install is the split-brain hook: Husky wires core.hooksPath to .husky, but a stale .git/hooks/pre-commit left over from an older setup, or a global git core.hooksPath set on the host, can shadow or compete with it. Because git only consults one hooks path, a lingering configuration from a previous tool means the hook you carefully installed never fires even though the file is present and executable. When a hook stubbornly does nothing, inspect git config --get core.hooksPath inside the container to confirm it resolves to .husky and not to some inherited value, and clear any old scripts under .git/hooks that predate the Husky migration.
| Symptom | Root Cause | Remediation |
|---|---|---|
| husky install fails on create | Ran in onCreate before .git mount | Move it to postCreateCommand |
| Hooks not firing | husky install never ran | Run npx husky install in postCreate |
| Hook exists but does nothing | lint-staged not configured | Add lint-staged config |
| Reinstall noise on rebuild | Non-deterministic install | Use npm ci; husky install is idempotent |
Conclusion
The whole trick is timing: Husky writes into .git/hooks, which only exists after the workspace is mounted, so husky install belongs in postCreateCommand, never onCreateCommand. Run it there, keep the install idempotent, and mirror the checks in CI so a bypassed hook is still caught.
The strategic payoff is that hook enforcement stops being a per-developer ritual and becomes a property of the environment. Once npx husky install lives in postCreateCommand behind a deterministic npm ci, every contributor who opens the container inherits the same pre-commit behaviour without reading a setup README or remembering a manual command. New teammates, ephemeral cloud workspaces, and CI runners all reconverge on the identical hook wiring, which is exactly the reproducibility guarantee a devcontainer is supposed to deliver. The .git bind mount is the one moving part, and pinning the install to the lifecycle moment right after it appears removes the guesswork.
This ties directly into the broader pin-and-cache theme that runs through containerized toolchains. Pinning the Husky version through package-lock.json and installing with npm ci means the hook logic is versioned alongside the code it guards, so a formatter or linter rule cannot silently drift between machines. Because the install is idempotent, the postCreate step is safe to cache and re-run without accumulating state, and because the same checks are mirrored in CI, the local hook is a fast feedback loop rather than the sole line of defence. Fast local enforcement plus an authoritative CI backstop is the durable shape: Husky catches problems in seconds at the keyboard, and the pipeline guarantees nothing merges even if someone commits with --no-verify.
FAQ
Why does husky install fail during container creation?
Because it ran in onCreateCommand, which executes before your workspace — and therefore .git — is bind-mounted. Husky has no .git/hooks to write into. Move npx husky install to postCreateCommand, which runs after the mount, and it succeeds. The same failure appears if Husky is triggered through npm's prepare script during image build, since that also fires while .git is absent. The reliable fix in both cases is to make the install an explicit step at a lifecycle moment you control, once the repository is present, rather than something the package manager runs on its own schedule.
Do I need CI checks if Husky runs the hooks?
Yes. Husky hooks can be bypassed with git commit --no-verify, and not every environment installs them. Mirror the same checks in CI as the enforced backstop; Husky gives fast local feedback, CI gives the guarantee. Treat the local hook as a convenience that shortens the feedback loop, not as a security boundary, because anything a developer runs locally they can also skip. The pipeline is the place where enforcement is non-negotiable, so run the same lint-staged equivalents there against the full diff and let merges block on their result.
How do I keep the Husky install from being noisy on rebuild?
Use npm ci for a deterministic dependency install, and rely on husky install being idempotent — re-running it simply re-points the hooks path without duplicating anything. Together they make the postCreateCommand safe to run on every rebuild. Neither command appends state, so there is no growing pile of duplicated hooks or half-installed dependencies to clean up between rebuilds. If you still see noise, it is usually a prepare script also firing during build, so removing that redundant trigger and keeping the single explicit postCreate call quiets the output.
Where does Husky actually store the hooks path?
Husky sets git's core.hooksPath to your .husky directory, a value written into .git/config. That is why the mounted .git has to exist first: without it there is no config to write. You can confirm the wiring at any time with git config --get core.hooksPath inside the container, and if it returns nothing or an unexpected path, the install either did not run or is being shadowed by an inherited global setting.
Can I use a different package manager like pnpm or yarn?
Yes, the pattern is package-manager agnostic. Swap npm ci for pnpm install --frozen-lockfile or yarn install --immutable to keep the deterministic-install property, and call husky install through the matching runner such as pnpm exec or yarn. The lifecycle rule does not change: run the install in postCreateCommand after the .git mount, and keep it idempotent so rebuilds stay clean regardless of which tool resolves the dependency tree.
Does the hook need lint-staged specifically?
No. lint-staged is a common choice because it runs formatters and linters only against staged files, which keeps the commit fast, but the .husky/pre-commit script can invoke any command you like. Whatever you put there should exit non-zero on failure so git aborts the commit, and it should be cheap enough that developers do not reach for --no-verify out of frustration. Heavy, whole-repository checks belong in CI rather than the pre-commit hook.
Related
- Up to Pre-commit Hook Configuration for Containerized Workflows — the parent guide on git hooks.
- Configuring pre-commit in a Multi-Language Repo — the polyglot alternative to Husky.
- Integrating ESLint & Prettier in DevContainers — the checks the hook runs.