Integrating ESLint & Prettier in DevContainers
Consistent linting and formatting is a team contract, and putting ESLint and Prettier inside the devcontainer — pinned in the project, wired to the editor, enforced in CI — removes the last excuse for "it passes on my machine." This guide, under the customization guide, focuses on the failure teams hit most: version skew between a developer's global formatter and the project's, which turns every save into a whitespace war.
The principle is single-source versioning. The formatter and linter live in the project's lockfile, the editor is pointed at those binaries (never a global install), and CI runs the same versions. When the editor, the pre-commit hook, and the pipeline all resolve identical tools, formatting is deterministic.
Prerequisites
You need ESLint and Prettier as pinned project dev dependencies, editor settings wiring format-on-save to the project formatter, checked-in config files, and a CI gate that runs the same versions.
eslintandprettierindevDependencies, pinned in the lockfile.customizations.vscode.settingssetting the default formatter andformatOnSave.eslint.config.js(flat config) and.prettierrccommitted.- A CI step running
eslint .andprettier --check ..
The prerequisite that quietly determines whether the whole system works is the distinction between a project tool and a global one, so it is worth being precise about it before the mechanics. When ESLint and Prettier are project dev dependencies pinned in the lockfile, every environment that installs the project — a developer's container, a teammate's container, the CI runner — resolves the exact same versions, because the lockfile pins them. A globally-installed formatter, by contrast, is whatever version each developer happened to install, updated on each developer's own schedule, invisible to the project. The entire "formatting is a team contract" promise rests on the tools being project-scoped: two developers running project-pinned Prettier format identically, while two developers running their own global Prettier format differently the moment their versions drift. The prerequisite is not just "have ESLint and Prettier" but "have them as pinned project dependencies, never global."
The editor-wiring prerequisite is the bridge that makes the project tools actually govern what happens on save, and it is easy to get half-right. Declaring editor.formatOnSave turns on automatic formatting, and editor.defaultFormatter names which extension does it, but the crucial behavior — that the Prettier extension uses the project's Prettier rather than a bundled or global one — depends on the extension resolving the project binary from node_modules. When Prettier is a dev dependency and the extension is configured for the workspace, it does this automatically, which is why the dev-dependency prerequisite and the editor prerequisite are two halves of one setup: the editor can only honor the project's formatter if the project's formatter is actually installed where the editor looks. Wiring the editor without pinning the tool, or pinning the tool without wiring the editor, leaves a gap where formatting drifts.
The checked-in-config prerequisite is what makes the rules — not just the tool versions — identical for everyone. ESLint's eslint.config.js and Prettier's .prettierrc encode the actual decisions: line length, quote style, which lint rules are errors. Committing these means the rules travel with the repository and apply uniformly, whereas rules configured only in a developer's personal editor settings apply only to that developer, producing the "your linter flags this, mine doesn't" divergence. The full contract requires all three prerequisites together — pinned versions so the tools behave identically, committed config so the rules are identical, and editor wiring so the editor applies both — because any one missing reintroduces a way for two developers' formatting to differ.
Architecture & Configuration Deep Dive
The integration is a four-layer stack that must agree end to end. The project dev dependencies fix the exact ESLint and Prettier versions. The config files define the rules, checked into the repo. The editor settings wire format-on-save to the project's Prettier via customizations.vscode. And CI runs the identical versions with --check so a mis-formatted file fails the build.
The one rule that makes this deterministic is that the editor must use the project's formatter, not a globally-installed one. A developer whose global Prettier is a minor version ahead will reformat files differently, producing diffs full of unrelated churn. Pointing the editor at the project binary — the default when Prettier is a dev dependency and the VS Code Prettier extension is configured to use it — eliminates that entire class of noise.
The four-layer stack is best understood as a single source of truth consumed in four places, which is what makes "agree end to end" more than a slogan. The pinned dev dependencies and the committed config together are the source of truth — they define exactly which tools, at which versions, applying which rules. The editor, the pre-commit hook, and CI are not three separate configurations; they are three consumers of that one source, each pointed at the same lockfile and the same config files. When all three consume the identical source, they cannot disagree, because there is nothing to disagree about — the same Prettier version applying the same .prettierrc produces the same output whether it runs on save, at commit, or in CI. The architecture's whole job is to prevent any consumer from resolving a different tool or config, because that is the only way the verdicts can diverge.
The division of responsibility across the three consumers is deliberate and mirrors the pre-commit model. The editor's role is immediate, invisible correction — format-on-save fixes formatting as you type, so most code is correctly formatted before it is ever committed and the developer never thinks about it. The pre-commit hook's role is cheap gatekeeping — it catches anything the editor missed (a file edited outside the editor, a developer with format-on-save off) before it enters history, running only on staged files for speed. CI's role is unskippable enforcement — it runs the full check on the server where no one can bypass it, so no mis-formatted or lint-failing code reaches the protected branch. Each consumer trades immediacy for authority: the editor is most immediate but skippable, CI is least immediate but absolute.
Understanding that layering explains why the editor-uses-the-project-formatter rule is load-bearing rather than a nice-to-have. If the editor formats with a different version than the pre-commit hook and CI use, then format-on-save produces output that the pre-commit hook or CI then rejects — the developer's editor "fixed" the file into a state the gate considers wrong. The result is the worst possible experience: the tool that is supposed to make formatting effortless actively fights the tool that enforces it. Pointing all three consumers at the identical project binary is what makes format-on-save and CI's --check two views of the same operation, so what the editor produces is exactly what CI accepts, with no reconciliation in between.
Step-by-Step Implementation
Install the tools pinned, add the config, wire the editor, and gate CI. In a devcontainer the install runs in postCreateCommand so every rebuild has the exact tools.
{
"name": "Lint + Format",
"image": "mcr.microsoft.com/devcontainers/base:ubuntu@sha256:PINNED",
"features": { "ghcr.io/devcontainers/features/node:1": { "version": "20" } },
"customizations": {
"vscode": {
"extensions": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"],
"settings": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"eslint.useFlatConfig": true
}
}
},
"postCreateCommand": "npm ci",
"remoteUser": "node"
}
Sharing one config across many packages without duplication is the monorepo case, covered in sharing ESLint/Prettier config across a monorepo. Enforcing the same tools at commit time is the job of pre-commit hooks.
The postCreateCommand: npm ci in the example is doing more than it appears, and the choice of npm ci over npm install is deliberate. npm ci installs strictly from the lockfile, failing if package.json and the lockfile disagree, which is exactly the guarantee you want: it reproduces the pinned ESLint and Prettier versions rather than potentially resolving newer ones. Because it runs in postCreateCommand on every create, every rebuild reinstalls the exact locked tools, so the container's formatter can never drift from what the lockfile specifies. Using npm install here would risk quietly updating the lockfile and therefore the tool versions, reintroducing the drift the whole setup exists to prevent — so the pinning discipline extends to how you install, not just what you install.
The flat-config detail (eslint.config.js with eslint.useFlatConfig) is worth calling out because ESLint's configuration format transitioned, and a mismatch here produces a confusing "config not found" failure even when a config file plainly exists. Modern ESLint uses the flat config format by default in recent versions, and the editor extension must be told to expect it via eslint.useFlatConfig; a project on flat config with an editor still expecting the legacy .eslintrc format will report that ESLint is not configured, despite the committed eslint.config.js. Matching the config format between the file, the ESLint version, and the editor setting is one of those small alignment requirements that, when wrong, masquerades as a bigger problem — and when right, simply works.
The remoteUser: node choice ties back to the security and permissions discipline from the architecture guides: the official Node image ships a non-root node user, and running as it keeps node_modules and any files the tools write correctly owned rather than root-owned. This matters for the caching story too — a node_modules volume owned by root while the session runs as node produces permission errors that look like tooling failures. Running as the image's intended non-root user, and mounting caches owned by that user, is what lets the pinned tools install once and be reused cleanly across rebuilds without ownership friction.
Performance & Resource Optimization
Lint feedback should be fast enough to run on every save and every commit. Linting the whole repository is fine in CI but slow for local feedback; linting only changed files, and using ESLint's cache, keeps it sub-second.
Use eslint --cache and lint staged files in the pre-commit hook (via lint-staged) so developers get instant feedback, while CI runs the full sweep for safety. The container's warm node_modules volume — from the cache strategy — keeps even the full run quick on rebuilds.
The scope-versus-position trade-off is the key to keeping lint fast without weakening enforcement. Linting the whole repository is thorough but slow, and running it on every save or every commit would make both painful; linting only the changed or staged files is fast enough to be invisible, but on its own would miss problems in files a given change did not touch. The resolution is to vary the scope by position: the editor and pre-commit hook lint narrowly (the file being edited, the staged files) for speed, while CI lints the whole repository for completeness. This is not a compromise on correctness — CI's full sweep guarantees nothing slips through — but a recognition that the fast, local positions only need to cover what changed, because CI covers everything.
ESLint's --cache is the second lever and it compounds with narrow scoping. The cache records each file's lint result keyed on its content, so an unchanged file is skipped entirely on the next run rather than re-linted. Combined with linting only staged files, this means a typical commit lints a handful of changed files and skips everything else via the cache, keeping the pre-commit run to a second or two. Persisting the cache and node_modules on named volumes — the same caching discipline the extension-cache guide describes — keeps even CI's full sweep quick on rebuilds, so neither the fast local positions nor the thorough CI position becomes the bottleneck that tempts developers to disable the checks.
Validation & Testing
Validate that the editor and the CLI resolve the same versions, and that what formatOnSave produces is exactly what CI's --check accepts. If they disagree, the editor is using a different (usually global) formatter.
# The project's pinned versions — editor and CI must match these
npx eslint --version && npx prettier --version
# CI parity: this must pass on a freshly formatted tree
npx prettier --check . && npx eslint .
The most decisive validation is a round-trip test: let the editor format a file on save, then run CI's exact check against it and confirm it passes. If formatOnSave produces output that prettier --check then rejects, the editor and CLI are resolving different versions — almost always the editor using a global Prettier while the CLI uses the project's. This test is powerful because it directly exercises the property that matters (what the editor produces is what CI accepts) rather than a proxy for it. Comparing npx prettier --version in the container against what the editor reports it is using pinpoints the mismatch when the round-trip fails, turning a vague "CI keeps failing formatting" complaint into a concrete version discrepancy you can fix.
Validating version parity across all three positions is worth doing explicitly rather than assuming the lockfile guarantees it. The lockfile pins the tool for anything that installs from it — the container and CI — but the editor's resolution is a separate path that depends on the extension configuration, which is exactly why it can drift. Confirming that the editor, the pre-commit hook, and CI all report the same ESLint and Prettier versions closes the loop: it proves the single source of truth is actually being consumed identically everywhere, not just intended to be. Because a version mismatch here is silent until it produces churn, an explicit parity check across the three positions is the reliable way to catch it before it sprays whitespace across a pull request.
Common Pitfalls
Formatting churn and rule disagreements almost always trace to a version or config mismatch. The triage below locates it.
| Symptom | Root Cause | Remediation |
|---|---|---|
| Save reformats unrelated lines | Editor uses a global Prettier version | Point the editor at the project's Prettier |
| CI fails formatting the editor accepts | Editor and CI on different versions | Pin versions; run the same tool in both |
| Rules differ between developers | Config not committed or overridden locally | Commit the config; disable local overrides |
| Lint is slow on every save | Linting the whole repo each time | Lint changed files with --cache |
| ESLint config not found | Flat config not enabled or misnamed | Use eslint.config.js and eslint.useFlatConfig |
The version-drift pitfall deserves expansion because it is the origin of the single most common formatting complaint — diffs full of churn on lines the author never touched. The mechanism is subtle: two Prettier versions can format the same code slightly differently (a trailing comma rule changes, a line-wrapping heuristic is adjusted), so when a developer with a newer global Prettier saves a file, their editor reformats the whole file to the newer style, and the diff shows dozens of unrelated lines changing. Reviewers waste time separating real changes from formatting noise, and the noise recurs every time anyone with a mismatched version touches the file. The fix is always the same — make the editor use the project's pinned Prettier — but recognizing the symptom (churn on untouched lines) as a version-drift signature is what points you at the cause quickly instead of blaming the diff tool.
The config-not-committed pitfall is the rule-level equivalent and it produces a quieter divergence. When the ESLint or Prettier config lives only in a developer's personal editor settings rather than a committed file, that developer's rules differ from everyone else's, so a construct one person's linter flags passes silently for another. Because nothing errors — each developer's setup is internally consistent — the divergence only surfaces in review or when CI (running the committed config, if there is one) disagrees with a local editor. The remediation is to commit the config and disable local overrides, so the repository is the single authority on the rules. Both pitfalls share the guide's central lesson: formatting and linting are deterministic only when the versions and the rules come from one committed source that every position consumes identically.
Conclusion
Pin the tools and config in the project, and enforce them everywhere the code is touched — the editor on save, the pre-commit hook at commit, and CI on push. The decisive rule is that the editor uses the project's formatter, never a global one, so a version drift on one laptop can't spray whitespace across everyone's diffs. With one source of truth for versions and config, formatting stops being a discussion.
Standing back, the reason this integration is worth building carefully is that formatting and linting disputes are pure overhead — they consume review attention and generate friction without improving the software, and they are entirely preventable. When the tools and rules come from one committed source that the editor, the commit hook, and CI all consume identically, formatting stops being something anyone discusses: code is formatted correctly on save, kept correct at commit, and enforced correct on push, all applying the same rules. Reviewers see only meaningful diffs, developers never argue over quote style, and "it passes on my machine" ceases to be possible because every machine resolves the same tools. The devcontainer is what makes this reliable: because the pinned tools and committed config are baked into the shared environment, there is no per-laptop state left for formatting to drift through. The payoff is not just cleaner code but reclaimed human attention that was being spent on a problem the configuration can eliminate.
This is, once more, the site's recurring principle: encode the good behavior in the shared, versioned configuration so it is inherited rather than remembered. A new teammate who opens the devcontainer gets the pinned ESLint and Prettier, the committed rules, format-on-save wired to the project tools, and CI enforcement — with no manual setup and no opportunity to drift onto a personal global install. Nobody has to remember to match versions, install the right extensions, or configure format-on-save; the environment does it. That is what separates a linting setup that works because everyone is disciplined from one that works because the configuration makes the correct, consistent behavior the path of least resistance.
FAQ
Why do my formatting diffs include lines I never touched? Because your editor is formatting with a different Prettier version than the rest of the team — usually a globally-installed one that is ahead of the project's pinned version. Make Prettier a project dev dependency and configure the editor to use the project's copy, so every save produces byte-identical formatting. The signature of this problem is churn on lines unrelated to your change: two Prettier versions wrap or punctuate slightly differently, so saving reformats the whole file to your version's style. Recognizing that churn-on-untouched-lines pattern as a version mismatch — rather than a diff-tool quirk — points you straight at the fix, which is pinning the editor to the project's Prettier.
Should linting run in the editor, pre-commit, or CI?
All three, at different scopes. The editor gives instant feedback on save, the pre-commit hook blocks mis-formatted commits (linting staged files for speed), and CI runs the full sweep as the backstop. They must all resolve the same pinned versions and committed config so their verdicts agree. Think of them as one check in three positions with increasing authority: the editor is immediate but skippable, the pre-commit hook is a cheap gate but bypassable with --no-verify, and CI is the unskippable final word. Because each covers the gaps of the one before it, dropping any position weakens the guarantee — most damagingly CI, without which the whole thing becomes advisory rather than enforced.
How do I keep lint fast in a large repo?
Lint only changed or staged files locally (with lint-staged) and enable ESLint's --cache, reserving the full-repository lint for CI. A warm node_modules cache volume keeps even the full run quick on rebuilds, so neither local feedback nor CI becomes a bottleneck. The principle is to vary scope by position — narrow and fast where feedback needs to be immediate, full and thorough where enforcement needs to be complete — so speed and coverage are handled in the places each matters rather than compromised into a single mediocre setting.
What's the difference between ESLint and Prettier, and do I need both? They solve different problems, so most teams use both. Prettier is a formatter: it rewrites code to a consistent style (indentation, quotes, line wrapping) without judging correctness. ESLint is a linter: it flags likely bugs and enforces code-quality rules. Because they overlap slightly on stylistic rules, the common pattern is to let Prettier own formatting entirely and configure ESLint to defer to it on style, so the two do not fight over the same lines. Pinning and enforcing both through the same four positions gives you consistent formatting and consistent code-quality rules from one committed source.
How does this interact with the monorepo case? In a monorepo the goal is one shared config consumed by every package rather than a copy per package, which the linked monorepo how-to covers. The single-source principle scales directly: a shared, versioned config at the root (or a shared config package) is the source of truth, and each package's editor, pre-commit hook, and CI job resolve that same config and the same root-pinned tool versions. The mechanics grow slightly — you resolve a shared config rather than a local one — but the determinism guarantee is identical, because there is still exactly one definition of the tools and rules that every position consumes.
Related
- Customization & Developer Toolchain Integration — the parent guide on reproducible toolchains.
- Sharing ESLint/Prettier Config Across a Monorepo — one config for many packages without duplication.
- Pre-commit Hook Configuration for Containerized Workflows — enforcing lint and format at commit time.
- Managing VS Code Extension Caches — caching the ESLint/Prettier extensions and node_modules.
- devcontainer.json Property Reference — the settings and postCreateCommand keys used here.