How do I run ESLint the same way locally and in CI?
To run ESLint the same way locally and in CI, run the project’s own ESLint and plugins on a supported Node.js version. Lint each package with its own config. Let lint errors fail the CI job, and apply --fix with the same ESLint and config. Teams usually meet these requirements with npx eslint . in a CI job and a Git pre-commit hook. Dagger’s official ESLint module meets the same requirements in one place, and dagger check runs it the same way on a laptop and in Cloud Checks.
What a consistent ESLint setup needs
ESLint’s own docs give four requirements for lint results that are the same on a laptop and in CI:
- Run the project’s own ESLint on a supported Node.js version. ESLint’s Getting Started page installs ESLint in the project, not globally. It says that plugins and shareable configs “must still be installed locally” even with a global ESLint. ESLint needs Node.js
^20.19.0,^22.13.0, or>=24. A lockfile such aspackage-lock.jsonrecords the exact versions of ESLint and its plugins. - Lint each package with its own config. ESLint “determines configuration for each target file by first looking in the directory that contains the file”, and then looks in the directories above it. The docs say that this lookup supports monorepos, “where subdirectories can have their own configuration files.”
- Fail the job on lint errors. ESLint exits with code 1 when it finds at least one error, and with code 2 on a configuration problem (exit codes). A run with only warnings exits with code 0. The
--max-warningsoption sets the number of warnings that makes ESLint exit with an error. - Apply fixes with the same ESLint and config. The
--fixoption writes fixes “to the actual files themselves”, and ESLint then reports “only the remaining unfixed issues”. Run it with the ESLint version and config that CI uses, so that the fixed files pass the CI check.
Meet them with npx eslint, a CI job, and a hook
By hand, each developer runs the project’s ESLint with npx, and a CI job runs the same command. This guide’s project has a root config and a second config in packages/ui. Only the packages/ui config turns on the prefer-const rule. Run ESLint from the project root:
npx eslint .
/work/packages/ui/src/button.js
2:7 error 'prefix' is never reassigned. Use 'const' instead prefer-const
✖ 1 problem (1 error, 0 warnings)
1 error and 0 warnings potentially fixable with the `--fix` option.
The command exited with code 1. ESLint used the packages/ui config for button.js, so one run from the root meets the second requirement. Fix the problem:
npx eslint . --fix
The command printed nothing and exited with code 0. It changed let prefix to const prefix in packages/ui/src/button.js.
In CI, a job installs Node.js, installs the dependencies, and runs npx eslint .. The exit code of ESLint fails the job. On GitHub Actions, actions/setup-node installs the Node.js version from its node-version input, or from a file such as .nvmrc through node-version-file. npm ci installs the exact versions in package-lock.json. GitLab CI and other systems run the same commands in a Node.js image (GitLab CI/CD YAML reference).
Many teams also run ESLint in a Git pre-commit hook, so that a developer sees problems before a push. ESLint’s integrations page lists hook setups. One of them lints only staged changes.
Where the hand-built setup strains
The hand-built setup meets each requirement in more than one place, and each place must stay in step:
- Node.js version. The CI job reads the version from its config or a version file. Each developer must switch their own Node.js to the same version.
- The project’s own ESLint.
npm ciin CI installs from the lockfile. A localnode_modulesthat is older than the lockfile runs a different ESLint or plugin version until the developer installs again. - Fixes. A pre-commit hook is optional.
git commit --no-verifyskips it, so CI is the first place that some problems show. - CI syntax. Each CI system has its own job syntax. A move to another CI system means a rewrite of the ESLint job.
Meet them with the Dagger ESLint module
The Dagger ESLint module meets the four requirements with one definition that runs on a laptop and in CI. A Dagger module is a package of functions that test, lint, or build a project. Official modules install from dagger.io/<module>. Complete the Quickstart first. It runs dagger init, which creates dagger.toml. This file holds the workspace configuration for everyone who uses the repository.
Install the ESLint module from the project root:
dagger module install dagger.io/js/eslint
dagger.io/js/eslint ➡️ https://github.com/dagger/eslint#v0.3.0
Installed module "eslint" in /work/dagger.toml
The command adds the module to dagger.toml:
[modules.eslint]
source = "dagger.io/js/eslint"
The command also writes dagger.lock, which pins the module to an exact Git commit. Commit both files. The next four sections show how the module meets each requirement.
Run the project’s own ESLint on a supported Node.js version
The module installs the project’s dependencies and runs the eslint binary from the project’s own node_modules, in the project directory. The result matches what developers see when they run ESLint locally. The module runs ESLint in the node:25-alpine image by default. Set baseImageAddress to the project’s Node.js version.
The install step runs without the source files. Thus, a lifecycle script such as postinstall or prepare fails if it needs source files. The --ignore-scripts flag stops these scripts (README). If an install script needs source files, set installFlags to ["--ignore-scripts"]:
dagger module settings eslint baseImageAddress node:22-alpine
dagger module settings eslint installFlags '["--ignore-scripts"]'
The settings go into dagger.toml, so every developer and every CI run uses the same ones:
[modules.eslint]
source = "dagger.io/js/eslint"
[modules.eslint.settings]
baseImageAddress = "node:22-alpine"
installFlags = ["--ignore-scripts"]
In a monorepo, the module installs dependencies at the nearest workspace root. A workspace root is a directory with pnpm-workspace.yaml, or with a package.json that has workspaces. If there is no workspace root, the module uses the nearest lockfile, and then the nearest package.json. The module mounts that tree, so shared configs and workspace: dependencies resolve (README). The module reference covers the packageManager and environment settings.
The install step reads only the package files and lockfiles, so a change to a source file does not start a new install (README). In this guide’s project, the first dagger check took 12.9 seconds, with the npm install. A rerun with no changes took 0.8 seconds. These numbers come from one run on one machine.
Lint each package with its own config
The module treats a directory as an ESLint project if the directory has one of these files:
- A flat config:
eslint.config.js,.mjs,.cjs,.ts,.mts, or.cts. - A legacy config:
.eslintrc,.eslintrc.js,.cjs,.json,.yaml, or.yml.
The module does not find a config that is only in the eslintConfig field of package.json. It also does not find a config that you give with --config (README).
A check is a function that passes or fails, such as a lint run. The ESLint module has one check, lint, and runs it once for each ESLint project. Add --all to list one row for each project:
dagger check -l --all
MODULE ESLINT-PROJECT CHECK DESCRIPTION
eslint . lint Lint this project.
eslint packages/ui lint Lint this project.
A nested project gets its own run with its own config, and the project around it skips the nested files. Thus, the module lints no file twice (README). To lint one project, add --eslint-project with its directory, relative to the workspace root. The root project is .. Repeat the flag to select more projects:
dagger check --eslint-project=packages/ui
== CHECKS == ✔ 1 passed
✔ dag://?check=lint&eslint-project=packages/ui 0.8s OK
To list the projects that you can select, run dagger list eslint-projects -a:
MODULE ESLINT-PROJECT DESCRIPTION
eslint . The ESLint project rooted at path.
eslint packages/ui The ESLint project rooted at path.
Fail the job on lint errors
dagger check runs every check in the workspace. When ESLint finds no errors, the check passes:
dagger check
== CHECKS == ✔ 1 passed
✔ dag://?check=eslint/projects/lint 8.4s OK
When ESLint reports an error, the check fails. The output shows ESLint’s report for each project that failed:
! ESLint failed in 1 of 2 projects:
- packages/ui: eslint failed (exit 1):
+ /src/node_modules/.bin/eslint .
/src/packages/ui/src/button.js
2:7 error 'prefix' is never reassigned. Use 'const' instead prefer-const
✖ 1 problem (1 error, 0 warnings)
1 error and 0 warnings potentially fixable with the `--fix` option.
== RUN LOCALLY ==
dagger check "dag://?check=eslint/projects/lint"
The module runs eslint . with no --max-warnings option (module source). A warning alone does not fail the check. To make a rule fail the check, set it to "error" in the ESLint config.
Apply fixes with the same ESLint and config
Each project has a fix function that runs ESLint’s --fix option in the same container as the check. The function returns the changes as a changeset, which is a set of file changes that you apply to your files. fix is not a check, so dagger check does not run it. Run it from the workspace root:
dagger -c 'eslint | projects | get packages/ui | fix | export .'
In this guide’s project, the command changed one line in packages/ui/src/button.js. This is the change that git diff showed:
- let prefix = "Button: ";
+ const prefix = "Button: ";
fix keeps the fixes even when ESLint cannot fix all problems. The check reports the problems that remain.
Run ESLint locally and with Cloud Checks
dagger check runs the same check on a laptop and in CI. Cloud Checks run your checks on Dagger’s Cloud Engines for each push and pull request, with no CI workflow file. The repository must be on GitHub, and its Git origin remote must point to that repository. Turn on Cloud Checks from the project root:
dagger cloud checks on
If you do not have a prerequisite, such as a Dagger Cloud account, the command prints the command to run first. Commit dagger.toml and dagger.lock, then push. Each push then runs the ESLint check. To see the results, run dagger workspace activity. If the check fails, run dagger check locally to see the same ESLint report.
If the project also formats code with Prettier, see How do I enforce Prettier formatting in CI?. Biome is one tool for both lint and format rules. How do I run Biome lint and format checks in CI? says when it fits.
Bottom line: Run the project’s own ESLint on a supported Node.js version, and let each package use its own config. Fail CI on errors, and run --fix with the same setup. By hand, that means npx eslint . in a CI job, a pinned Node.js version, and a pre-commit hook, each kept in step. The Dagger ESLint module meets these requirements in one place, and dagger check runs it the same way locally and in Cloud Checks.
Related guides
- How do I enforce Prettier formatting in CI?: check formatting next to ESLint, with the same project and the official Prettier module.
- How do I run Biome lint and format checks in CI?: one tool for lint and format rules, when it can replace ESLint and Prettier.
- How do I run Jest in CI and split the tests with –shard?: run JavaScript unit tests with the official Jest module.