The spark of this blog post came from a tweet from DHH about the default Rails 8 having a Github Actions workflow.

Github Actions is a great tool for automating your workflows. It's built into Github and is free for public repositories. However, if you have a private repository, you will need to pay for Github Actions. This can be a bit expensive if you have a lot of repositories. There is a way to reduce the cost of Github Actions by using self hosted runners. This allows you to run your Github Actions workflows on your own servers. This can be a great way to reduce the cost of Github Actions.

To be fair, Github Actions does have a free tier for private repositories. However, this free tier only allows for 2000 minutes of build time per month. This can be a bit limiting if you have a lot of repositories or if you have a lot of builds.

The good news is that you can set up your own self hosted runner for free. This will allow you to run your Github Actions workflows on your own servers. This can be a great way to reduce the cost of Github Actions.

Setting up a Self Hosted Runner

To set up a self hosted runner, you will need to have a server that you can SSH into. This can be a server that you already have or it can be a new server that you create. It could also be something like a Raspberry Pi or a Virtual Machine hosted in the cloud.

To start, we will use Debian 12 (Bookworm) as our base operating system. This is the latest version of Debian and it is a great choice for a server. It is lightweight and it is easy to install. Once you have a running Debian 12 server, we can SSH into it and update and install some packages.

apt update && apt upgrade -y && \
apt-get install -y \
    curl nano git gnupg apt-transport-https ca-certificates \
    software-properties-common make gcc autoconf patch \
    build-essential rustc libssl-dev libyaml-dev libreadline6-dev \
    zlib1g-dev libgmp-dev libncurses5-dev libffi-dev libgdbm6 \
    libgdbm-dev libdb-dev uuid-dev htop libpq-dev

We'll then create a new user for our runner so that we don't have to run everything as root.

adduser --quiet --disabled-password --shell /bin/bash --home /home/developer --gecos "Developer" developer

The runner will need to be able to run Docker containers, so we'll need to install Docker.

install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
echo \
"deb [arch="$(dpkg --print-architecture)" signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian \
"$(. /etc/os-release && echo "$VERSION_CODENAME")" stable" | \
tee /etc/apt/sources.list.d/docker.list > /dev/null
apt update && apt install docker-ce -y

And the developer user will need to be able to run Docker containers without sudo.

usermod -aG docker developer

Since I typically use esbuild on my projects, It'll be best to install nodejs and yarn.

curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | \
    gpg --dearmor -o /etc/apt/nodesource.gpg && \
    echo "deb [signed-by=/etc/apt/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" | \
    tee /etc/apt/sources.list.d/nodesource.list && \
    apt update && apt install nodejs -y && \
    npm install --global yarn

We can then log in as the developer user to install Ruby.

su - developer

On my development machines, I prefer to use asdf, but for a runner, rbenv is a great choice.

git clone https://github.com/rbenv/rbenv.git ~/.rbenv
echo 'eval "$(~/.rbenv/bin/rbenv init - bash)"' >> ~/.bashrc
git clone https://github.com/rbenv/ruby-build.git "$(rbenv root)"/plugins/ruby-build
git -C "$(rbenv root)"/plugins/ruby-build pull
echo 'export PATH="$HOME/.rbenv/plugins/ruby-build/bin:$PATH"' | tee -a ~/.bashrc
source ~/.bashrc

We can then install the Github Actions Runner. You can check for the latest versions on the Github Actions Runner Releases page.

mkdir actions-runner && cd actions-runner
export RUNNER_VERSION="2.311.0"
curl -o actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz -L https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz
tar xzf ./actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz

You'll need to register this runner, so be sure to check out the steps below on how to do that. You'll need to run the registration script in the folder that you extracted the runner to. I keep the default settings in wizard of the script.

To register the runner, head over to one of your projects on Github and select the Actions > Runners. Then click on the New self-hosted runner button. Select your operating system and architecture.

Under the Configure section, there will be a script to run that starts with ./config.sh and ends with a token. Copy this script and run it on your server. This will register the runner with Github. This will need to be done in the folder that you extracted the runner to.

# ./config <- run the config registration script from below here.

Installing Ruby

We can now install our Ruby version. I've updated all of my projects to Ruby 3.3.0, so that's the version that I'll be using. However, modify this as needed. This is also assuming that you're installing this on an amd64 machine. If you're using a Raspberry Pi or something else, you'll need to modify the path. It's also important to let the runner know that we've installed Ruby so we will touch a file to let the runner know that this Ruby version is present.

export RUBY_VERSION="3.3.0"
ruby-build ${RUBY_VERSION} /home/developer/actions-runner/_work/_tool/Ruby/${RUBY_VERSION}/x64
touch /home/developer/actions-runner/_work/_tool/Ruby/${RUBY_VERSION}/x64.complete

Starting the Runner at boot

We'll edit the /etc/systemd/system/github-runner.service file and place the following contents in it. This will allow us to start the runner at boot.

[Unit]
Description=GitHub Runner
After=network.target

[Service]
User=developer
WorkingDirectory=/home/developer/actions-runner
ExecStart=/home/developer/actions-runner/run.sh
Restart=always
RestartSec=3
LimitNOFILE=4096

[Install]
WantedBy=multi-user.target

Reload systemd, enable and start the service

systemctl daemon-reload
systemctl enable github-runner
systemctl start github-runner

Conclusion

We now have a self hosted runner that we can use for our Github Actions workflows. This will allow us to run our workflows on our own servers. This can be a great way to reduce the cost of Github Actions. If you have any questions or comments, please feel free to reach out. I'm always happy to help.