self-host →

Taking Omarchy's nightly ISO build from 13 minutes to under 5

Two workflow changes cut Omarchy’s nightly ISO build to 4m43s: a faster runs-on runner and skipping compression on an already compressed artifact.

One of the things DHH has been obsessed with is making Omarchy install as fast as possible and honestly, getting it down to under one minute on some machines is really impressive.

But at runs-on, we couldn’t help but ask does it build fast on GitHub Actions too?

Here’s what we found out:

Omarchy does a simple nightly build#

The omarchy-iso repo has a nightly-build.yml that runs every night. It builds the complete Omarchy ISO and uploads it as a GitHub artifact which is ~6.2GB at the moment.

I ran the same job five times on ubuntu-latest runners in both a public and private repository and this what the timings looked like:

RunnerAvg run timeAvg cost per run
ubuntu-latest (public)12m44s$0.00
ubuntu-latest (private)19m04s~$0.118

The difference you see here is because the ubuntu-latest machines on public repos have double the vCPU and RAM than those on private repos, while being free to use as well.

The job has 6 steps with 3 of them doing most of the work:

Stepubuntu-latest (public)ubuntu-latest (private)
Free disk space1m40s2m22s
Build ISO7m57s13m09s
Upload artifact3m02s3m28s

What’s the build actually doing?#

The Build ISO step starts an Arch Linux Docker container and runs the build-iso.sh script inside it.

That script installs the Arch ISO tooling, downloads roughly 1,250 packages from Arch, Omarchy and related repositories, and turns them into a local package repository before assembling the final ~6.2GB ISO.

That repository is included in the ISO so Omarchy can install the complete system without downloading packages from the internet.

The live filesystem is stored in a SquashFS image compressed with zstd. The package files are already zstd-compressed, so they are not compressed again inside SquashFS.

This gives us enough to optimise the workflow for performance:

1/ Change the runner machine#

The ubuntu-latest runner for public repositories has 4 vCPUs, 16GB of memory and a 14GB SSD, but 14GB is nowhere near enough for this build.

The offline Arch packages, Docker data, intermediate files and final ISO can all exist on disk at the same time which is why the workflow spends almost two minutes running a separate step that deletes preinstalled software from the runner.

This is also documented in this commit.

So the first change was replacing ubuntu-latest with a runs-on runner:

runs-on: ubuntu-latest
runs-on: runs-on=${{ github.run_id }}/family=m8azn.xlarge/image=ubuntu24-full-x64/volume=50gb
ubuntu-latest (public)ubuntu-latest (private)runs-on m8azn.largeruns-on m8azn.xlarge
vCPUs4224
Memory16GB8GB8GB16GB
Root volumeFixed 14GB SSDFixed 14GB SSDVariable, 30GB minimum (50GB here)Variable, 30GB minimum (50GB here)
PassMark score2,195–3,4792,195–3,4794,2694,269
Price/minuteFree$0.006$0.001 Spot / $0.003 on-demand$0.003 Spot / $0.007 on-demand

The m8azn family is the best general-purpose compute AWS currently offers. The large matches the 2 vCPUs and 8GB of memory in GitHub’s private runner, while the xlarge matches the public runner’s 4 vCPUs and 16GB.

Both runs-on configurations use a newer, faster CPU and let us choose the volume size in the label itself.

Which means we can get rid of this step:

name: Free Disk Space
uses: jlumbroso/free-disk-space@v1.3.1

After running the migrated workflow, we can observe the following improvements in both run time and cost:

RunnerAvg run timeBuild ISOUpload artifactAvg cost On-DemandAvg cost Spot
runs-on m8azn.large7m21s ↓61%5m36s ↓57%1m41s ↓51%~$0.0225 ↓81%~$0.0081 ↓93%
runs-on m8azn.xlarge5m48s ↓54%4m05s ↓49%1m39s ↓46%~$0.0417 ↓65%~$0.0177 ↓85%

RunsOn costs include runner compute and the 50GB gp3 volume.

2/ Stop compressing an already compressed ISO#

The final significant step uploads the 6.2GB ISO to GitHub and actions/upload-artifact compresses uploads at level 6 by default.

But the data being uploaded here already looks like this:

GitHub artifact compression
└── Omarchy ISO
└── zstd-compressed SquashFS filesystem
└── individually compressed Arch packages

So compressing it again saved almost no space but consumed CPU and added more than a minute to the upload.

So we just changed the the GitHub compression level to 0 here:

- name: Upload newly built ISO artifact
uses: actions/upload-artifact@v4
with:
name: omarchy-iso-${{ github.run_number }}
path: ./release/*.iso
retention-days: 1
compression-level: 0

This changes only the compression used by GitHub while uploading the artifact and does not change the SquashFS settings or anything inside the Omarchy ISO

In the measured runs, the artifact grew by only around 0.6%, while upload times dropped substantially:

RunnerAvg run timeBuild ISOUpload artifactAvg cost On-DemandAvg cost Spot
runs-on m8azn.large5m55s ↓69%5m18s ↓60%33s ↓84%~$0.0180 ↓85%~$0.0066 ↓94%
runs-on m8azn.xlarge4m43s ↓63%4m06s ↓48%32s ↓82%~$0.0338 ↓71%~$0.0144 ↓88%

The final workflow#

The resulting job is still very close to the original but with a better right-sized machine and 0 compression:

jobs:
build-iso:
runs-on: runs-on=${{ github.run_id }}/family=m8azn.xlarge/image=ubuntu24-full-x64/volume=50gb
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Build ISO
run: NO_BOOT_OFFER=1 ./bin/omarchy-iso-make
- name: Delete previously uploaded ISO artifact
uses: philips-labs/action-delete-artifacts@v1.0.0
with:
workflow: nightly-build.yml
continue-on-error: true
- name: Upload newly built ISO artifact
uses: actions/upload-artifact@v4
with:
name: omarchy-iso-${{ github.run_number }}
path: ./release/*.iso
retention-days: 1
compression-level: 0

Across the measured Spot and on-demand runs, m8azn.xlarge averaged 4m43s: a 63% reduction in run time versus the public ubuntu-latest runner.

The m8azn.large averaged 5m55s: a 69% reduction in run time versus the private ubuntu-latest runner. On-demand cost fell by 85% per run, with Spot increasing the saving to 94%.

There was no single complicated optimisation here.

I inspected what the workflow was actually doing and gave it the resources it needed and stopped compressing an already compressed 6.2GB file.