Our team has built several GitHub Actions workflows over the last year, and also many Continuous Integration (CI) pipelines at big software companies for many years. In this guide, we’ve collected five practical tips that can help you speed up your CI today.
In this fast-paced technology market, fast delivery is essential to stay competitive. To achieve this, software teams need to build and iterate features rapidly.
Slow GitHub Actions can negatively impact developers' productivity. Typically, developers consider their task as “done” only when the CI completes successfully. Waiting for long workflows to complete leads to frequent context switches back and forth between new and current tasks. This wastes developers’ time. Companies prioritizing fast CI can ensure their developers remain productive and focused, delivering high-quality features.
Causes for slow GitHub Actions
There are many reasons why GitHub Actions workflows are slow. Here is a list of the most common causes:
- Repeated work: every workflow needs tools, software dependencies, and container images to complete successfully. Downloading and installing these dependencies can take time.
- Workflow design: workflow steps can run unnecessarily, or their execution order can be inefficient.
- Sequential execution: workflow steps run sequentially within a single job.
- Runner Machines: workflow uses Runner instances with the wrong architecture (e.g. arm64 or amd64) or lacking compute resources (e.g. CPU and RAM).
Speed Up Strategies for GitHub Actions
Here we list the most effective strategies to improve GitHub Action speed.
Design Workflows for Speed
It is tempting to design a single Workflow that takes care of everything: launching static code analyzers, building the whole application, running unit and integration tests, and finally releasing every CI artifact. Every commit into the repo triggers this gigantic pipeline, which might take a while to complete.
Try instead to split your CI into multiple single-purpose workflows.
Use path
filters to trigger the workflows only when certain parts of the code are changed.
For example, building and testing the frontend code should be a separate workflow
from the backend code. The same concept can go even further; for example, your
backend might be written in Rust and Go. You can have two workflows, one for
Rust and one for Go, that run only when the respective source code is modified.
Thorough integration testing is mandatory for a high-quality product, but running
it on each commit is expensive and unnecessary. You can leave a subset of integration
tests that are fast to complete in the per-commit workflow and move the rest into a
separate workflow. This new workflow can then use schedule
to run periodically
(e.g. every night). This approach gives the team enough confidence that their commits
do not break the product without waiting for the entire test suite.
Order of steps matters. Run first the steps that are fast to complete but might fail often. For example, code formatters and linters are good candidates: they are usually quick, and you want to know that your code is not formatted correctly before waiting more than 10 minutes for the whole workflow to run.
Don’t repeat yourself - Use Cache
Don’t waste time downloading the same set of dependencies over and over. Intermediate results of previous Workflow job runs can be reused in new runs.
Consider using pre-baked GitHub Actions that take care of caching for you,
such as action/cache
or whywaita/actions-cache-s3
.
Storage space is not free, so be careful what you store in the cache.
GitHub provides language-specific actions that automatically set up
language toolchains and perform caching on language artifacts.
For example, if you are a Gopher, you can use actions/setup-go@v5
to install Go and cache modules.
For a simple application like our
guestbook-go
installing Go from official website and run go test
can take up to 25s.
- name: Install Go
run: |
curl -OL https://go.dev/dl/go1.20.6.linux-amd64.tar.gz
sudo rm -rf /usr/local/go
sudo tar -C /usr/local -xzf go1.20.6.linux-amd64.tar.gz
export PATH=$PATH:/usr/local/go/bin
go version
- name: Check Go Formatting
run: go fmt ./... && git diff --exit-code
- name: Check Go Mod Tidyness
run: go mod tidy && git diff --exit-code
- name: Test
run: go test ./...
The actions/setup-go@v5
action and its automatic caching save us 19s (down to 6s)!
- uses: actions/setup-go@v5
with:
go-version: '>=1.20.0'
- name: Check Go Formatting
run: go fmt ./... && git diff --exit-code
- name: Check Go Mod Tidyness
run: go mod tidy && git diff --exit-code
- name: Test
run: go test ./...
Take Advantage of Parallelism
Jobs that are independent of one another can run in parallel.
Steps within a job can be moved into a new job and run in parallel.
There are cases, however, where one job depends on the results of other jobs.
Use needs
to specify a sequential dependency between two jobs.
Maintaining the configuration of many parallel jobs can become problematic very quickly.
Use matrix
to write fewer job templates and configure them to run in parallel.
To limit the maximum number of parallel jobs, use max-parallel
.
This is especially useful if you have a private repository that
consumes runner minutes from GitHub. Each minute costs you money,
and you might want to protect yourself from large bills.
When using matrix
, make sure that fail-fast
is not configured to false (it’s true by default). This configuration
will stop in-progress and queued jobs if one job belonging to the same matrix
fails.
This is especially important if you are paying for GitHub runner minutes.
The matrix
size can become bigger over time and failing jobs can waste your money.
Note, the fail-fast
mechanism only applies to the matrix
definition.
So, two jobs listed separately like the following (i.e. jobA
and jobB
) will not stop each other in case the other fails.
jobs:
jobA:
runs-on: [ubuntu-latest]
steps:
- ...
jobB:
runs-on: [ubuntu-latest]
steps:
- ...
In this case, you can use the andymckay/cancel-action@0.3
action to cancel the entire workflow run when the failure condition applies.
jobs:
jobA:
runs-on: [ubuntu-latest]
steps:
- ...
- uses: vishnudxb/cancel-workflow@v1.2
with:
repo: octocat/hello-world
workflow_id: ${{ github.run_id }}
access_token: ${{ github.token }}
if: failure()
jobB:
runs-on: [ubuntu-latest]
steps:
- ...
- uses: vishnudxb/cancel-workflow@v1.2
with:
repo: octocat/hello-world
workflow_id: ${{ github.run_id }}
access_token: ${{ github.token }}
if: failure()
One final note on cost management. Be aware that parallelism can really speed up the whole GitHub Workflow, but that does not necessarily mean you are saving money. On the contrary, every job minute is counted independently. If, for example, the whole workflow takes 1 minute to complete, but it runs 5 parallel jobs of 1 minute each, then the final bill will be for 5 minutes, not 1!
Run on beefier Runners
The amount of compute resources on the runner can easily become
the bottleneck for resource-intensive tasks, such as compiler,
Docker builds, and tests. You can use GitHub Actions, such as
runforesight/workflow-telemetry-action
to track the resources
utilization during a job run. If CPU or memory load is often 100%,
your job might benefit from a bigger runner machine.
GitHub default runners have 2 vCPU and 7 GB of RAM. Larger runners are available to customers under the paid GitHub Team and GitHub Enterprise plans.
An attractive alternative is to deploy self-hosted runners in your own infrastructure and manage them. We’ve talked about the pros and cons of this approach here.
We see externally managed runners as striking a good balance between being simple to use, and offering the best price/performance ratio. This frees you and your team precious time to focus on your core business.
Offload Special Tasks
Certain types of special tasks benefit from specialized environments designed to run those tasks efficiently. A perfect example is Docker builds. By nature, Docker builds take advantage of faster CPU clocks, persistent caching, and CPU architecture for multi-platform builds.
Running Docker builds in GitHub Actions usually takes time, especially for multi-platform builds, as it needs to rely on QEMU hardware virtualization.
Another example of a special task that can benefit from offloading is Kubernetes. Teams often use Kubernetes to manage their applications in production. When it comes to CI, setting up a Kubernetes cluster and running tests against it in a GitHub Action is possible but typically slow.
How Namespace can help
We faced these very problems at Namespace and worked hard to find solutions. Here is where Namespace can help you.
Namespace Runners
You can get more powerful runners with Namespace. Our default runner shape is 8 vCPU and 16 GB of RAM. We found that beefier runners speed up many CI with a simple one-line change in your workflow file.
You also get more more control over parallelism with finer cost management. You can decide the shape of the runners you want to use for a given job. You know that your CI is memory hungry? No problem, specify more RAM in the runner label.
Running your first workflow on Namespace takes less than a minute. Give it a try!
Specialized Compute
We've talked before about how offloading special tasks to optimized machines can make GitHub Actions faster.
With Namespace, offloading Docker builds to high-performant machines only requires a one-line change in your GitHub workflow file.
You can use the nscloud-setup-buildx-action
action to offload any Docker build to Namespace.
For a simple application like our
guestbook-go
building a Docker image for linux/arm64 and linux/amd64 can take up to 3 minutes!.
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
# Setup build driver to use Buildkit container
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ env.IMAGE_REPO }}/${{ env.IMAGE_NAME }}:${{ env.VERSION }}
platforms: linux/amd64,linux/arm64
However, with Namespace remote Builders we saved more than 2 minutes (down to 16s)!
- name: Configure access to Namespace
uses: namespacelabs/nscloud-setup@v0
# Use Namespace Builders
- name: Configure buildx
uses: namespacelabs/nscloud-setup-buildx-action@v0
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ env.IMAGE_REPO }}/${{ env.IMAGE_NAME }}:${{ env.VERSION }}
platforms: linux/amd64,linux/arm64
If you want to run CI tests against a production-like Kubernetes cluster, Namespace gives you
test clusters outside of the resource-constrained runner.
Just call nscloud-cluster-action
action to get
a cluster in less than 10 seconds.
Conclusion
In summary, consider these tips to speed up your GitHub Actions workflow.
- Redesign the workflow: split it into smaller workflows, trigger them only when necessary, and move fast but more likely to fail tests first.
- Cache: use cache providers to store dependencies and artifacts.
- Parallelism: split the workflow into independent jobs and run them in parallel.
- Use more powerful runners: default GitHub runners can be the bottleneck of your CI. Consider using bigger runners from GitHub or external providers.
- Offload special tasks: certain tasks, such as Docker build and Kubernetes, can run slowly in default GitHub runners. Consider leveraging an external provider for specialized environments to run these tasks.