How to Set Up CI/CD for Rust Projects (GitHub Actions)

Set up GitHub Actions CI/CD for Rust by creating a main.yml workflow file that runs tests and lints on every push.

The robot that catches what you miss

You push a commit that passes every test on your laptop. You merge the pull request. Ten minutes later, a teammate reports the build is red. The CI runner caught a race condition that only appears under load, or a lint flagged a clippy warning you ignored, or the documentation build failed because a dependency updated upstream. Your code is live, but the project is broken. You need a gatekeeper that runs before the merge, not after.

Continuous Integration is a robot reviewer that lives in your repository. Every time you push code, the robot wakes up, builds your project from scratch, runs your tests, checks your formatting, and reports back. If anything fails, the robot blocks the merge. You can't sneak bad code past it. It treats every commit like it's the first time it's seeing the code, which means it catches the "works on my machine" lies that humans miss.

How CI works for Rust

A CI workflow is a script that runs on a fresh virtual machine. The machine has no Rust installed. It has no dependencies cached. It has no idea what your project does. Your job is to write a configuration file that tells the machine exactly how to build and test your code.

The configuration lives in .github/workflows/main.yml. GitHub reads this file and triggers the workflow based on events like pushes or pull requests. The workflow defines jobs. Each job runs on a runner, which is a virtual machine provided by GitHub. Inside each job, you list steps. Steps can be shell commands or reusable actions written by the community.

The golden rule of Rust CI is reproducibility. If the build works on your machine but fails in CI, the problem is almost always a missing dependency, a different toolchain version, or an environment variable. Your workflow must specify every tool and version explicitly. Never assume the runner has Rust installed. Never assume the stable channel is the one you want. Pin everything.

Minimal workflow

Start with the absolute basics. This workflow checks out your code, installs Rust, and runs the test suite. It runs on every push and pull request.

name: CI
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      # Get the code from the repository
      - uses: actions/checkout@v4
      
      # Install Rust and cache dependencies automatically
      - uses: dtolnay/rust-toolchain@stable
      
      # Run the test suite
      - name: Run tests
        run: cargo test

The on key triggers the workflow. [push, pull_request] means it runs when you push to any branch and when you open or update a pull request. The jobs section defines the work. Here, there is one job named test.

The runs-on: ubuntu-latest line picks the operating system. GitHub provides Ubuntu, Windows, and macOS runners. Ubuntu is the standard for most Rust projects because it's fast and matches the environment of many deployment targets.

The first step uses actions/checkout@v4. This action clones your repository into the runner's workspace. Without this, the runner has nothing to build.

The second step uses dtolnay/rust-toolchain@stable. This is the community standard for setting up Rust in GitHub Actions. It installs the Rust toolchain and handles caching behind the scenes. It caches the ~/.cargo directory and the target directory based on the toolchain version and the contents of your Cargo.lock. This means subsequent runs skip downloading crates and recompiling unchanged dependencies. The build finishes in seconds instead of minutes.

The final step runs cargo test. This compiles the project and executes all tests. If any test fails, the step returns a non-zero exit code, and the job fails.

Pin your toolchain. Drift is the enemy.

The source of truth: rust-toolchain.toml

Hardcoding the Rust version in the workflow file creates a mismatch. Your workflow might use stable, but your Cargo.toml might require a newer feature. Or your workflow might use 1.75, but you update your local toolchain to 1.76 and forget to update the workflow. The build breaks, or worse, it succeeds with the wrong version.

The convention is to use a rust-toolchain.toml file in your project root. This file declares the toolchain version and components your project requires.

[toolchain]
channel = "1.75.0"
components = ["clippy", "rustfmt"]

This file pins the channel to 1.75.0. It also requests the clippy and rustfmt components. When you run cargo locally, rustup reads this file and switches to the specified toolchain automatically. The dtolnay/rust-toolchain action also reads this file. If you specify @stable in the workflow, the action looks for rust-toolchain.toml, sees the pinned version, and installs that version instead.

This keeps the source of truth in one place. Update the version in rust-toolchain.toml, and both your local development and your CI stay in sync. You don't need to touch the workflow file when you update Rust.

Trust the toolchain file. Let the action do the work.

Realistic workflow with lints and cache

A production workflow does more than run tests. It checks formatting, runs lints, and validates documentation. It also splits work into parallel jobs to save time.

name: CI
on: [push, pull_request]

env:
  # Disable incremental compilation in CI. It saves disk space and speeds up builds.
  CARGO_INCREMENTAL: 0
  # Enable backtraces for better error messages if tests panic.
  RUST_BACKTRACE: 1

jobs:
  lint:
    name: Lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      # dtolnay reads rust-toolchain.toml and installs components listed there.
      - uses: dtolnay/rust-toolchain@stable
      - name: Check formatting
        run: cargo fmt --check
      - name: Run clippy
        run: cargo clippy -- -D warnings

  test:
    name: Test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      # Cache cargo registry and target directory.
      - name: Cache dependencies
        uses: actions/cache@v4
        with:
          path: |
            ~/.cargo/registry
            ~/.cargo/git
            target
          key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
      - name: Run tests
        run: cargo test

The env block sets environment variables for all jobs. CARGO_INCREMENTAL=0 disables incremental compilation. Incremental compilation stores intermediate artifacts to speed up recompilation during development. In CI, you build from scratch every time. Incremental compilation adds overhead and wastes disk space. Disabling it makes the build faster and uses less storage.

The lint job checks code style and quality. cargo fmt --check verifies that the code is formatted correctly. It fails if any file differs from the output of cargo fmt. This ensures everyone uses the same formatting without requiring developers to run cargo fmt manually. The convention is to run cargo fmt locally before committing, but the CI check catches mistakes.

cargo clippy -- -D warnings runs the clippy linter. The -D warnings flag treats all warnings as errors. If clippy finds any issue, the build fails. This enforces best practices and catches common mistakes. You can configure clippy allowlists in clippy.toml if you need to suppress specific warnings for your project.

The test job runs the test suite. It uses actions/cache@v4 to cache dependencies. The path specifies the directories to cache. The key determines when the cache is invalidated. ${{ hashFiles('**/Cargo.lock') }} creates a hash of the lockfile. If the lockfile changes, the hash changes, and the cache is invalidated. This ensures the cache is fresh when dependencies update.

Split jobs for parallelism. Linting and testing can run at the same time.

Performance tuning and conventions

CI speed matters. Slow feedback loops discourage developers from running tests. Every second saved adds up over hundreds of runs.

Use dtolnay/rust-toolchain for caching. The action implements sophisticated caching logic that outperforms manual cache steps in most cases. It caches based on the toolchain hash and the lockfile. It also handles cache restoration gracefully when the key doesn't match exactly. If you use dtolnay, you often don't need a separate actions/cache step for Rust dependencies. The action handles it.

Set CARGO_INCREMENTAL=0. This is mandatory for CI. Incremental compilation is slower in a clean build scenario. It generates extra files and consumes more CPU. The CI runner is ephemeral. There is no benefit to keeping incremental artifacts.

Use RUST_BACKTRACE=1. When a test panics, the backtrace shows the call stack. This makes debugging failures much easier. Without it, you only see the panic message. The backtrace helps you pinpoint the source of the error.

Run cargo test --all-features if your crate has optional features. This ensures that all feature combinations compile and pass tests. Feature flags can hide compilation errors that only appear when certain features are enabled. Testing all features catches these issues early.

Don't let CI be a speed bump. Cache your dependencies and disable incremental compilation.

Pitfalls and compiler errors

CI workflows expose issues that local development hides. Understanding common failures saves time.

If you see E0432 (unresolved import) in CI but not locally, check your feature flags. A dependency might be behind a feature that isn't enabled in the default build. CI might be running with different features. Verify that cargo test and cargo build use the same feature set.

If you see E0277 (trait bound not satisfied), look for missing trait implementations. This error often appears when a dependency updates and changes its API. The lockfile might be outdated. Run cargo update locally to sync with the latest versions, then commit the new lockfile.

If the build fails with "command not found" for clippy or rustfmt, ensure the components are installed. The rust-toolchain.toml file should list the components. The dtolnay action installs components listed in the file. If you run cargo clippy manually in a step, make sure the component is available.

Network timeouts can occur when downloading crates. GitHub Actions runners have reliable internet, but the crates.io index can be slow. The dtolnay action and actions/cache mitigate this by caching downloads. If timeouts persist, check the runner logs for network errors.

Flaky tests are a CI killer. A test that passes locally but fails intermittently in CI indicates a race condition or a dependency on system state. Fix flaky tests by isolating resources, using deterministic seeds for randomness, or mocking external dependencies. Never ignore flaky tests. They erode trust in the CI system.

Treat warnings as errors. The compiler is your friend.

Decision matrix

Use dtolnay/rust-toolchain when you want automatic toolchain management and dependency caching without writing shell scripts. Use actions/cache when you need to cache build artifacts that dtolnay doesn't handle, such as generated files or third-party binaries. Use cargo clippy -- -D warnings when you want the CI to fail on any clippy warning, enforcing code quality standards. Use cargo fmt --check when you want to ensure consistent formatting across the team without modifying files in CI. Use rust-toolchain.toml when you need to pin the Rust version and components for your project, ensuring consistency between local development and CI. Use a matrix strategy when you need to test against multiple Rust versions or operating systems.

Pin your versions. Cache your artifacts. Fail fast.

Where to go next