Skip to content

Colin Webb

Using Github Actions for building Rust Docker Images on ARM

Setting up CI for Rust with Github Actions is pretty easy, but there are a few gotchas.

This post will walk through setting up a basic CI pipeline for a Rust project, cross-compile to ARM, and build a Docker image.

I like to deploy hobby-projects on ARM as it is usually cheaper, and I have a Macbook so there's less cross-compilation to do.

We'll follow a pattern of building Rust, and then copying the binary into a Docker image. There are other patterns, such as building inside Docker using multi-stage builds, but there are unresolved issues with caching. This way is simpler, and works well.

The code

Firstly, the code. You can find full working code for this example on Github here.

Here, I'll briefly present the code and then break it down piece by piece.

Below is a simple hello-world Axum webserver. This is a pretty standard example, and is mostly taken from the docs. I'm hoping this should be understood without explanation, but if not, please reach out!

use axum::{response::Html, routing::get, Router};

#[tokio::main]
async fn main() {
    let app = Router::new().route("/", get(hello_world));

    axum::Server::bind(&"0.0.0.0:8080".parse().unwrap())
        .serve(app.into_make_service())
        .await
        .unwrap();
}

async fn hello_world() -> Html<String> {
    Html("hello world".to_string())
}

Next, the Dockerfile.

Again, I'm hoping this doesn't need much explanation. We're using the distroless cc arm64 image, and copying the Rust binary into it.

"Distroless" images contain only your application and its runtime dependencies. They do not contain package managers, shells or any other programs you would expect to find in a standard Linux distribution.

The cc image contains libgcc1 and its dependencies, which is required for our Rust binary to run.

FROM gcr.io/distroless/cc-debian12:latest-arm64
COPY target/aarch64-unknown-linux-gnu/release/rust-gha-example /
ENTRYPOINT [ "/rust-gha-example" ]

Lastly, the Github Actions yaml file. We'll break this down in detail later, but for now, here's the full file.

name: Build and push Docker image
on:
  push:
    branches:
      - main

env:
  IMAGE_NAME: ghcr.io/${{ github.repository }}

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Login to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Install Rust Toolchain
        uses: dtolnay/rust-toolchain@stable
        with:
          toolchain: stable
          targets: aarch64-unknown-linux-gnu

      - name: Install compiler tools (as we're cross-compiling)
        run: sudo apt install -y gcc-aarch64-linux-gnu

      - name: Setup caching
        uses: Swatinem/rust-cache@v2

      - name: Build release artifact
        run: cargo build --release --target aarch64-unknown-linux-gnu

      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: |
            ${{ env.IMAGE_NAME }}:latest
            ${{ env.IMAGE_NAME }}:${{ github.sha }}

We also need the following in .cargo/config.toml.

[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"

This tells Cargo to use a specific linker when building our binary. I'll come back to this later.

Finally, we need to ensure Github Actions has write permissions onto the repository it is building so that the Docker image can be pushed to the container registry.

This is a setting in Github. It can be found in the repository's settings under 'Actions, General, Workflow Permissions'.

A Simple Example

If we remove ARM, and caching, we have a simpler example. Let's look at that first.

name: Simple build and push Docker image
on:
  push:
    branches:
      - main

env:
  IMAGE_NAME: ghcr.io/${{ github.repository }}

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Login to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Install Rust Toolchain
        uses: dtolnay/rust-toolchain@stable
        with:
          toolchain: stable

      - name: Build release artifact
        run: cargo build --release

      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: |
            ${{ env.IMAGE_NAME }}:latest
            ${{ env.IMAGE_NAME }}:${{ github.sha }}

If you're unfamiliar with Github Actions there are excellent docs here.

Essentially, the CI does these five things at this stage:

  • checkout code
  • login to container registry
  • install rust toolchain
  • build rust binary
  • build and push docker image

David Tolnay's dtolnay/rust-toolchain is the only non-standard action here. The author is a prolific contributor in the Rust ecosystem, and the action is well maintained.

Add in ARM cross-compilation

Next, we add ARM cross-compilation. Three things are needed, plus tackling a gotcha.

We need to add the target to the rust toolchain, and add a flag to cargo build.

We need to install compiler tools so that cross-compiling can happen.

- name: Install Rust Toolchain
  uses: dtolnay/rust-toolchain@stable
  with:
    toolchain: stable
    targets: aarch64-unknown-linux-gnu

- name: Install compiler tools (as we're cross-compiling)
  run: sudo apt install -y gcc-aarch64-linux-gnu

- name: Build release artifact
  run: cargo build --release --target aarch64-unknown-linux-gnu

Plus, as you can see from the Docker image, we need to reference where the built binary is.

COPY target/aarch64-unknown-linux-gnu/release/rust-gha-example /

The gotcha here is that we also need to configure cargo to use the correct linker. This is done in .cargo/config.toml.

Without this, there will be lots of linking errors.

[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"

At this stage, we should have Github Actions fully building and pushing an ARM-based Docker image.

Next, let's make it faster.

Add Caching

Our approach to caching is to use Swatinem/rust-cache. This caches ~/.cargo and .target directories between builds.

It uses a cache-key related to the toolchain you're compiling with, so it must be added after installing the Rust toolchain.

- name: Setup caching
  uses: Swatinem/rust-cache@v2

There are other options for caching such as sccache. I've used this before, but it doesn't cache downloadable dependencies - only the compiled versions, so its a little slower for this use-case.