Skip to content
ExponaBLOG
RUST PROGRAMMING · DAY 063 / 100

Rust in CI: everything engineers need to know in practice

Fast, reliable pipelines.

Expona Admin7 min read

Introduction

Continuous integration for Rust projects requires a different mindset than many languages. Your CI pipeline needs to account for compilation overhead, dependency caching strategies, and a rich ecosystem of linting and security tools that developers expect to run before code reaches production. Rust in CI: everything engineers need to know in practice means setting up pipelines that are fast, reliable, and catch problems early—without burning through build minutes or frustrating your team with flaky workflows.

We've built and maintained dozens of Rust services across trading platforms, e-commerce backends, and ride-hailing infrastructure. In this article, we'll walk through the practical decisions that separate slow, frustrating CI pipelines from the ones that consistently deliver confidence and speed.

Cache target and registry folders

The single biggest mistake teams make with Rust CI is treating the build directory as disposable. Unlike many languages where recompilation is fast, Rust's compilation is expensive. Without proper caching, a 3-minute build becomes a 12-minute build, and your developers start skipping local checks.

The two folders you must cache are target/ (where compilation artifacts live) and .cargo/registry/ (where downloaded dependencies are stored). GitHub Actions, GitLab CI, and CircleCI all support caching, but the implementation differs slightly.

Here's a practical GitHub Actions workflow that caches both directories:

rust
name: CI

on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - uses: dtolnay/rust-toolchain@stable
      
      - uses: Swatinem/rust-cache@v2
        with:
          cache-directories: |
            target
            .cargo/registry
          cache-on-failure: true
      
      - name: Build
        run: cargo build --release

The Swatinem/rust-cache action handles most of the complexity for you—it automatically keys the cache by your Cargo.lock file and OS, so changes to dependencies invalidate the cache appropriately. On subsequent runs, you'll see build times drop by 70–90% for unchanged code.

If you're using GitLab CI, the approach is similar but manual:

rust
cache:
  key: $CI_COMMIT_REF_SLUG
  paths:
    - target/
    - .cargo/registry/

build:
  stage: build
  script:
    - cargo build --release

One caveat: caching works best when your dependencies are stable. If you have a Cargo.toml that pins exact versions, cache hits are frequent. If you're using version ranges or pulling from git, expect more cache misses and longer pipelines.

fmt, clippy and test steps

Linting and formatting in Rust isn't optional—it's the foundation of code quality. Most production Rust teams run three checks before anything else: cargo fmt (code formatting), cargo clippy (linting), and cargo test (test suite). These steps catch issues at the source and keep your codebase consistent.

Formatting is non-negotiable. Run cargo fmt --check in CI to reject any code that doesn't match the project's formatting rules. This prevents endless discussions about whitespace and bracket placement.

Clippy is your static analyzer. It finds common mistakes, suggests idiomatic Rust patterns, and catches performance issues. Configure it to deny warnings on critical projects:

rust
steps:
  - name: Check formatting
    run: cargo fmt -- --check
  
  - name: Run clippy
    run: cargo clippy -- -D warnings
  
  - name: Run tests
    run: cargo test --lib --doc

For clippy, the -D warnings flag turns warnings into errors, failing the build. On large codebases, you might want to start with -W warnings (warnings only) and gradually increase strictness.

Tests deserve their own step. Run cargo test --lib --doc to execute unit tests and documentation tests separately from integration tests. Documentation tests are underrated—they keep your examples current and verify that your public API works as documented.

A real-world example from an e-commerce backend we maintained:

rust
test:
  stage: test
  script:
    - cargo test --lib --doc -- --test-threads=1
    - cargo test --test '*' --release
  artifacts:
    reports:
      junit: target/junit.xml

We run doc tests serially (single-threaded) to avoid flaky failures from shared state, then run integration tests in release mode to catch optimization-related bugs. The JUnit report integrates with GitLab's UI so the team can see which tests failed without diving into logs.

cargo audit for vulnerabilities

Security vulnerabilities in your dependencies don't announce themselves. cargo audit checks your Cargo.lock` against a database of known vulnerabilities and fails the build if any are found. It's free, fast, and should run on every CI pipeline.

Install it and add it to your workflow:

rust
- name: Install cargo-audit
  run: cargo install cargo-audit

- name: Audit dependencies
  run: cargo audit

Or, if you prefer to keep it lightweight, use the audit subcommand directly if it's already installed in your CI image.

In production systems, we've caught a handful of serious vulnerabilities this way—outdated cryptographic libraries, parsing bugs in serialization crates, and memory safety issues. One memorable case was a transitive dependency with a known DoS vulnerability that we caught before it made it to production.

Configure audit to ignore certain vulnerabilities if you have a documented mitigation plan, but don't disable it outright. The configuration lives in Cargo.toml or a dedicated .cargo/audit.toml file:

rust
[advisories]
ignore = [
  { id = "RUSTSEC-2021-0001", reason = "Mitigated by X" }
]

Treat audit failures the same way you treat test failures—they block merges and demand immediate attention.

Build release binaries on tags

Most of your CI runs are on pull requests and pushes to main. But when you tag a release, you need a different workflow: build optimized binaries, run a final test suite, and publish them to your artifact repository or registry.

Tag-triggered builds are where you build release binaries with cargo build --release. This mode enables compiler optimizations, increases build time, and produces smaller, faster binaries. Reserve this for releases, not every commit.

Here's a practical GitHub Actions workflow for tag-based releases:

rust
on:
  push:
    tags:
      - 'v*'

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - uses: dtolnay/rust-toolchain@stable
      
      - uses: Swatinem/rust-cache@v2
      
      - name: Run tests
        run: cargo test --release
      
      - name: Build release binary
        run: cargo build --release
      
      - name: Create GitHub Release
        uses: softprops/action-gh-release@v1
        with:
          files: target/release/my-app

For services deployed to container registries, build a Docker image instead:

rust
      - name: Build and push Docker image
        run: |
          docker build -t myregistry/my-service:${{ github.ref_name }} .
          docker push myregistry/my-service:${{ github.ref_name }}

In a CRM system we maintained, we tagged releases with semantic versioning (e.g., v1.2.3). The CI pipeline automatically built binaries, ran a comprehensive test suite, and pushed Docker images tagged with the version number. This gave us reproducible, traceable releases with minimal manual steps.

Cross-compilation is worth mentioning here. If your Rust service needs to run on multiple architectures (ARM for Kubernetes on AWS Graviton, for example), use cargo build --target aarch64-unknown-linux-gnu in your release workflow. Most teams use tools like cross to simplify this:

rust
      - name: Install cross
        run: cargo install cross
      
      - name: Build for multiple architectures
        run: |
          cross build --release --target x86_64-unknown-linux-gnu
          cross build --release --target aarch64-unknown-linux-gnu

Common mistakes

  • Not caching dependencies: Every run compiles from scratch, turning a 3-minute build into 15 minutes. Cache target/ and .cargo/registry/ from day one.
  • Skipping fmt and clippy: Code style inconsistencies and missed optimizations accumulate. Make these checks mandatory, not optional.
  • Running cargo audit once a week: Run it on every commit. Vulnerabilities are discovered constantly, and you want to know immediately if your dependencies are affected.
  • Rebuilding the entire project on release tags: Reuse cached artifacts from your main branch to speed up release builds. Only rebuild if dependencies have changed.
  • Single-threaded test runs everywhere: Most tests can run in parallel. Use parallel execution on CI to keep build times reasonable, then run serially locally if you hit flaky tests.
  • Ignoring compilation time in PR reviews: If your CI takes 20 minutes, developers won't run it locally. Keep it under 5 minutes when possible, or split long-running checks into separate jobs that don't block merges.

Key takeaways

  • Cache target/ and .cargo/registry/ aggressively to reduce build times by 70–90% on cache hits.
  • Run cargo fmt --check, cargo clippy -- -D warnings, and cargo test on every commit to catch issues early.
  • Use cargo audit on every build to block merges when dependencies have known vulnerabilities.
  • Reserve cargo build --release for tag-triggered workflows to minimize compilation overhead on regular commits.
  • Treat CI failures (test failures, audit failures, lint failures) with the same urgency as production incidents—they block releases for a reason.
  • Monitor your CI build times and cache hit rates. When either degrades, investigate immediately.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles