Every commit should pass formatting checks, linting, compile-time query validation, and tests before it reaches the main branch. This section covers GitHub Actions workflows for Rust projects, building Docker images with cached dependency layers, and pushing images to a container registry.
GitHub Actions workflow structure
The workflows below use GitHub Actions. Forgejo Actions uses a compatible YAML format, so these workflows transfer to a self-hosted Forgejo instance with minor adjustments (runner labels, service hostnames). See the Forgejo section below for specifics. Keeping workflow files in .github/workflows/ works in both systems.
Split CI into parallel jobs for fast feedback. Formatting and linting fail quickly and cheaply. Tests take longer and need service containers. Running them in separate jobs means a formatting mistake fails in seconds, not after a full build.
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
CARGO_TERM_COLOR: always
jobs:
fmt:
name: Formatting
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- run: cargo fmt --all -- --check
clippy:
name: Clippy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- uses: Swatinem/rust-cache@v2
- run: cargo clippy --all-targets --all-features -- -D warnings
test:
name: Test
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 --workspace
sqlx-check:
name: SQLx Cache
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- run: cargo install sqlx-cli --no-default-features --features postgres
- run: cargo sqlx prepare --workspace --check
Four jobs, each with a single responsibility:
- fmt checks formatting with
rustfmt. No caching needed since it does not compile code. - clippy runs the Rust linter with warnings treated as errors (
-D warnings). Caching avoids recompiling dependencies on every run. - test runs the full test suite. The testing section covers test setup in detail, including testcontainers for PostgreSQL and Redis,
axum_testfor handler testing, and SQLx offline mode for compile-time query checking without a live database. - sqlx-check verifies the
.sqlx/query cache is current. This catches stale query metadata before it reaches production.
Toolchain and caching
dtolnay/rust-toolchain installs the Rust toolchain. Specify the channel via the @ref syntax: @stable, @nightly, or a pinned version like @1.84.0. The components input adds tools like clippy and rustfmt.
Swatinem/rust-cache@v2 caches the ~/.cargo registry and ./target directory between runs. It builds cache keys from Cargo.toml, Cargo.lock, and the Rust compiler version, so a toolchain update or dependency change invalidates the cache automatically. Place it after the toolchain setup but before any build steps.
Without rust-cache, every CI run rebuilds the full dependency tree from scratch. For a typical Axum application with 100+ transitive dependencies, that adds several minutes to every pipeline.
The fmt job skips caching intentionally. cargo fmt --check only parses the source; it does not compile anything.
Service containers for integration tests
If your test suite needs PostgreSQL, Redis, or other services and you are not using testcontainers (which manages containers itself), GitHub Actions can start service containers:
test:
name: Test
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: test_db
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Run tests
run: cargo test --workspace
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/test_db
REDIS_URL: redis://localhost:6379
The services block starts Docker containers before the job’s steps run. Health checks ensure the service is ready before tests begin. When the job runs directly on the runner (not inside a container), services are accessible at localhost on the mapped port.
The testcontainers approach described in the testing section manages containers per test and does not need the services block. Both approaches work in CI. Testcontainers gives per-test isolation; service containers give a single shared instance.
Building Docker images
Rust compiles to a single static binary. The build image needs the full Rust toolchain; the runtime image only needs the binary. A multi-stage Dockerfile separates these concerns.
Dockerfile with cargo-chef
cargo-chef caches dependency compilation across Docker builds. Without it, changing a single line of application code triggers a full rebuild of every dependency. With it, dependencies only rebuild when Cargo.toml or Cargo.lock change. The speedup is typically 3-5x.
The pattern uses three stages:
# Stage 1: Compute a dependency-only build recipe
FROM lukemathwalker/cargo-chef:latest-rust-1 AS chef
WORKDIR /app
FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
# Stage 2: Build dependencies, then the application
FROM chef AS builder
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json
COPY . .
RUN cargo build --release --bin myapp
# Stage 3: Runtime
FROM debian:bookworm-slim AS runtime
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN groupadd -g 1001 appgroup && \
useradd -u 1001 -g appgroup -m appuser
COPY --from=builder --chown=appuser:appgroup /app/target/release/myapp /usr/local/bin/myapp
USER appuser
ENTRYPOINT ["/usr/local/bin/myapp"]
How it works:
- The planner stage scans all
Cargo.tomlfiles andCargo.lockand producesrecipe.json, a manifest of your dependency tree with workspace structure. - The builder stage first runs
cargo chef cookwith the recipe. This downloads and compiles all dependencies. Docker caches this layer. As long asrecipe.jsonhas not changed (meaning your dependencies are the same), this step is a cache hit on subsequent builds. Then it copies the full source and compiles the application. Only this final compilation step runs on each code change. - The runtime stage copies the compiled binary into a minimal Debian image.
ca-certificatesis included for TLS connections. The application runs as a non-root user.
The lukemathwalker/cargo-chef base image bundles cargo-chef with the official Rust image. The latest-rust-1 tag tracks the latest Rust 1.x release.
Runtime image choices
debian:bookworm-slim (~80 MB) provides a good balance of size and debuggability. It includes a shell, basic tools, and glibc. For production troubleshooting, being able to docker exec into a container and run basic commands is worth the extra megabytes.
If image size or attack surface is a hard requirement, consider gcr.io/distroless/cc-debian12 (~20 MB, no shell) or scratch (empty, requires a fully statically linked binary built with musl). For most applications, bookworm-slim is the practical choice.
Pushing to a container registry
After the CI jobs pass, build the Docker image and push it to a registry. The image job depends on the test jobs so that broken code never produces an image.
GitHub Container Registry
image:
name: Build and Push Image
runs-on: ubuntu-latest
needs: [fmt, clippy, test, sqlx-check]
if: github.ref == 'refs/heads/main'
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/metadata-action@v5
id: meta
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=sha
type=raw,value=latest,enable={{is_default_branch}}
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
This job runs only on pushes to main (not on pull requests). The GITHUB_TOKEN is automatically available and has sufficient permissions for GHCR when the packages: write permission is set.
The metadata-action generates two tags: a short commit SHA (sha-abc1234) for traceability, and latest for the default branch. The commit SHA tag lets you trace any running container back to the exact commit that produced it.
cache-from: type=gha and cache-to: type=gha,mode=max use the GitHub Actions cache backend for Docker layer caching. Combined with cargo-chef inside the Dockerfile, this means dependency layers are cached both within Docker and across CI runs.
Self-hosted container registry
If you run a self-hosted Forgejo instance or another registry, the workflow is the same pattern with different login credentials:
- uses: docker/login-action@v3
with:
registry: ${{ secrets.REGISTRY_URL }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
Replace the images value in the metadata-action to match your registry URL. The rest of the workflow is identical.
Conventional commits
Conventional Commits is a format for commit messages that makes the history machine-readable. Each message starts with a type, an optional scope, and a description:
feat(auth): add password reset flow
fix(search): handle empty query parameter
docs: update deployment instructions
refactor(db): extract connection pool setup
chore(ci): update rust-toolchain to 1.84
The common types are feat (new feature), fix (bug fix), docs, refactor, test, chore, and ci. A ! after the type or scope (e.g. feat!: or feat(api)!:) signals a breaking change.
The value of this convention is consistent, scannable history. When every commit follows the same format, it becomes trivial to see what changed, filter commits by type, or generate changelogs automatically.
Several tools automate workflows around conventional commits. git-cliff generates changelogs from commit history using configurable templates. cocogitto provides commit validation, version bumping, and changelog generation in a single Rust-native binary. Both are actively maintained and integrate with CI pipelines. Choose based on what you actually need, whether that is changelog generation, commit validation, automated version bumps, or all three.
Enforcing the format in CI is optional but straightforward. A validation step on pull requests catches non-conforming commits before they reach the main branch.
Self-hosted CI with Forgejo
Forgejo is a self-hosted Git forge with its own CI system, Forgejo Actions. Workflow files use YAML syntax similar to GitHub Actions and can be placed in .forgejo/workflows/ or .github/workflows/. Many GitHub Actions concepts carry over: triggers, job matrices, service containers, and step syntax.
Forgejo Actions is still marked as experimental. The main differences from GitHub Actions:
- The default runner image is minimal Debian with Node.js, not the full Ubuntu image GitHub provides. You may need to install additional build tools.
- Service container networking uses the service label as the hostname (e.g.
postgresrather thanlocalhost) since both the job and the service run on the same Docker network. - Some GitHub Actions marketplace actions need modification to work with Forgejo.
- The Forgejo Runner must be installed separately from the Forgejo instance, ideally on a different machine.
For a project that currently uses GitHub Actions and may migrate later, keep workflows in .github/workflows/. Forgejo falls back to that directory when .forgejo/workflows/ does not exist, which means the same workflow files work in both systems with minor adjustments for runner labels and service hostnames.
Gotchas
GitHub Actions cache limit. GitHub enforces a 10 GB total cache limit per repository. Rust builds produce large target directories. If caches are evicted frequently, check that Swatinem/rust-cache is configured with save-if: ${{ github.ref == 'refs/heads/main' }} to avoid filling the cache with every PR branch.
SQLx offline mode is required in CI. SQLx’s compile-time query checking connects to a live database. During CI compilation, no database is running yet. Run cargo sqlx prepare --workspace locally and commit the .sqlx/ directory. The sqlx-check job in the workflow above catches stale metadata.
cargo-chef and workspace layout. cargo chef prepare scans all Cargo.toml files in the workspace. If your workspace structure changes (adding or removing crates), the recipe changes and the dependency cache invalidates. This is correct behaviour. What can catch you off guard: the planner stage copies the entire source tree. If you have large non-Rust files (assets, data), consider adding a .dockerignore file to exclude them from the build context.
Docker BuildKit is required for cache mounts. The docker/setup-buildx-action enables BuildKit automatically in GitHub Actions. If building locally, ensure BuildKit is enabled (DOCKER_BUILDKIT=1 or Docker Desktop with BuildKit as default).
Container registry authentication in CI. The GITHUB_TOKEN has sufficient permissions for GHCR if the workflow sets permissions: packages: write. For self-hosted registries, store credentials as repository secrets, never in the workflow file.