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

A solid GitLab CI pipeline for Rust: pinned toolchain, cached target/, cargo check + test + clippy + fmt. Cache the Cargo.lock-keyed target directory and you'll cut build times from minutes to seconds.

What CI buys you

You push a fix, the tests pass on your laptop, you merge. A week later, a colleague discovers the change broke a feature flag combination you never tested. The problem is universal in software, but it's especially painful in Rust, where compile-time checks lull you into thinking "if it compiles it probably works."

CI is the answer that everyone agrees on. Run the build and the tests on a clean machine, every time anyone pushes. GitLab CI is convenient if you're already on GitLab: the runners are bundled, the YAML config lives next to your code, and there's nothing extra to install.

The shape of a Rust pipeline is roughly the same on every system. Pull a Rust toolchain, fetch dependencies, run cargo check, run cargo test, run cargo clippy, run cargo fmt --check. The interesting bits are caching, choosing a base image, and getting fast feedback when things break.

A minimum viable pipeline

# .gitlab-ci.yml — lives at the repo root.
stages:
  - test

test:
  stage: test
  # Official Rust image. Pin a version: "rust:1.83" or similar.
  # Avoid `latest` so your builds are reproducible.
  image: rust:1.83
  script:
    # Show toolchain info once per run; helps when debugging.
    - rustc --version && cargo --version
    # The actual work. cargo test compiles in dev profile by default,
    # which is fine for CI (faster than --release).
    - cargo test --all-features --workspace

That's enough to give you a green or red badge on every push. It's slow, because it doesn't cache anything, but it works.

Caching the target/ and ~/.cargo directories

Rust's compile times are the killer. Without caching, every CI run downloads every dependency from crates.io and recompiles them from scratch. With caching, you can usually keep that under thirty seconds.

# Reusable defaults. Every job that 'extends: .rust' inherits these.
.rust:
  image: rust:1.83
  variables:
    # Cargo's home is normally /usr/local/cargo in the official image.
    # Move it inside the project so the cache: paths can pick it up.
    CARGO_HOME: $CI_PROJECT_DIR/.cargo
  before_script:
    - rustc --version
  cache:
    # Per-branch cache. Switch to "$CI_COMMIT_REF_SLUG" for branch-isolated
    # caches, or "default" if every branch should share a single cache.
    key:
      files:
        - Cargo.lock
    paths:
      - .cargo/
      - target/
    policy: pull-push

stages:
  - check
  - test

# `cargo check` is much faster than `cargo build`. Run it as the first job
# so a syntax error fails the pipeline within seconds.
check:
  extends: .rust
  stage: check
  script:
    - cargo check --workspace --all-features

test:
  extends: .rust
  stage: test
  needs: ["check"]
  script:
    - cargo test --workspace --all-features

Two things worth flagging:

  • The cache key is Cargo.lock. If a dependency changes, the cache invalidates and CI rebuilds. If Cargo.lock is unchanged, you reuse the same target/ from the previous run. This is the right tradeoff for most projects.
  • policy: pull-push means the cache is pulled at the start of the job and pushed at the end. If you have a slow uploader and want only key jobs to push, set policy: pull on the others.

Adding clippy and rustfmt

Lints and formatting checks are cheap to run and worth their weight in saved review time.

lint:
  extends: .rust
  stage: check
  script:
    # Install components on the fly. The official rust:* image already has
    # cargo and rustc; clippy and rustfmt are bundled in modern releases.
    - rustup component add clippy rustfmt
    # -- separates cargo's args from clippy's args.
    # -D warnings turns every clippy warning into an error.
    - cargo clippy --workspace --all-features --all-targets -- -D warnings
    - cargo fmt --all -- --check

If you want clippy and the test job to run in parallel, give them the same stage and drop the needs:. GitLab's default is to run jobs in a stage in parallel.

Pinning a toolchain

The rust:1.83 image is fine for a quick start, but real projects want their toolchain version under source control. Use a rust-toolchain.toml at the repo root:

# rust-toolchain.toml — committed to the repo. rustup respects this
# and switches to the listed channel automatically.
[toolchain]
channel = "1.83.0"
components = ["clippy", "rustfmt"]

In CI, switch the base image to a slimmer one and let rustup handle the rest:

.rust:
  # Slim image, no preinstalled rustc. Lets rust-toolchain.toml drive.
  image: rustlang/rust:nightly-slim
  variables:
    CARGO_HOME: $CI_PROJECT_DIR/.cargo
  before_script:
    # Reads rust-toolchain.toml and installs the right channel + components.
    - rustup show

Now bumping rustc is a one-line change in rust-toolchain.toml, applied uniformly to every developer's machine and to CI.

Producing release artefacts

When CI finishes a build on a tagged commit, you usually want the binary as a downloadable artefact.

release:
  extends: .rust
  stage: test
  rules:
    # Only on tag pushes that look like vX.Y.Z.
    - if: '$CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/'
  script:
    - cargo build --release --workspace
  artifacts:
    name: "$CI_PROJECT_NAME-$CI_COMMIT_TAG"
    paths:
      - target/release/my-binary
    expire_in: 30 days

GitLab will attach the binary to the pipeline page. Combined with release rules, you can also automatically create a Git release with that artefact attached.

Speeding up further

A few tricks once your pipeline is the bottleneck:

  • Use cargo nextest instead of cargo test. It runs tests in parallel processes (rather than threads) and is noticeably faster on large suites.
  • Add sccache with a shared S3 backend so caches survive across branches and machines.
  • Split jobs by feature flag set or workspace member to parallelise. GitLab's parallel: and parallel:matrix: keywords are good for this.
  • Avoid --release builds in CI unless you're producing artefacts. Dev profile is faster to compile and almost always good enough for tests.

Common failures and what they mean

error: failed to run custom build command for `openssl-sys`

Your dependency needs system libraries that aren't in the base image. Add apt-get install -y libssl-dev pkg-config to before_script, or switch to a base image that already has them.

error: linker `cc` not found

The slim Rust images don't include a C linker. Add apt-get install -y build-essential.

warning: unused import: ...

Combined with -D warnings, this fails clippy. Either fix the warning or be pragmatic and use -W warnings for non-blocking advice. Most teams find that "warnings are errors" pays off long term.

Where to go next