{
  "$type": "site.standard.document",
  "content": "---\ntitle: \"Cutting ruby CI pipeline times with pre-installed bundles\"\ndescription: \"A multi-stage Dockerfile that pre-installs Ruby gems to cut GitLab CI build\n  times for Jekyll sites by up to 90%.\"\ntags:\n  - dev\n---\n\nI (and, increasingly many of my colleagues) are using\n[Jekyll](https://jekyllrb.com) to create open (CC-licenced), hackable, acessible\ncourse websites & teaching content for our classes. We use a self-hosted GitLab\nserver for all the websites sources, and then build/deploy them with\n[GitLab CI](https://docs.gitlab.com/ee/ci/). It works well, it means I don't\nhave to fight with our LMS to do interesting things, and it means I can open my\nlearning materials to everyone (not just those who are privileged enough to be\nable to pay the fees to study at the ANU).\n\nThe `jekyll build` step runs in a container, and for a long time we've just used\nthe [official ruby image](https://hub.docker.com/_/ruby/) as a starting point,\nthen done a `bundle install` inside the container before running the build step\nto get all the deps. However, this means the deps are _installed from scratch on\nevery deploy_, which isn't the greenest (although ANU is\n[heading in a good direction on net zero](https://www.anu.edu.au/research/research-initiatives/anu-below-zero))\nand it also means the feedback loop from push->deployed site is much longer than\nit needs to be.\n\nYesterday (prompted by the understandable frustrations of my colleague\n[Charles](https://charlesmartin.com.au) about the build times) I spent some time\nfixing things. I ended up creating a new docker image with the required gems\npre-installed, and it **cut our CI pipeline times by up to 90%** (i.e. a 10x\nspeedup).\n\nThere were a couple of tricky parts, so I include some commentary here in case\nanyone else (including future me when if I forget how this works) wants to do\nsimilar things.\n\n```dockerfile\n# Choose and name our temporary image.\nFROM ruby:3.0.2 as builder\n\nWORKDIR /app\n\n# Take an SSH key as a build argument.\nARG SSH_PRIVATE_KEY\n\n# required to pull from the (private) theme gem repos\n# create the SSH directory.\nRUN mkdir -p ~/.ssh/ && \\\n  # populate the private key file.\n  echo \"$SSH_PRIVATE_KEY\" > ~/.ssh/id_rsa && \\\n  # set the required permissions.\n  chmod -R 600 ~/.ssh/ && \\\n  # add our GitLab server to our list of known hosts for ssh.\n  ssh-keyscan -t rsa gitlab.anu.edu.au >> ~/.ssh/known_hosts\n\n# install the deps - this is really just for \"caching\", the expectation is that\n# the CI job will re-run `bundle install` to pick up any differences\nCOPY Gemfile Gemfile.lock* .\nRUN bundle install\n\n# Choose the base image for our final image\nFROM ruby:3.0.2\nWORKDIR /app\n\n# Copy across the files from our `builder` container\n# this really assumes the same base container\nCOPY --from=builder $BUNDLE_APP_CONFIG $BUNDLE_APP_CONFIG\n```\n\nThe main tricky bit is the ssh setup, because some of the (in-house) gems are\nonly available in git repos which require authentication. This Dockerfile pulls\nin the SSH key from an environment variable, then uses it to `bundle install`\nthe required gems. Then, the key part is that there's a second `FROM` command to\ncreate a new image (sans any trace of the SSH key) and only the installed gems\nare copied across.\n\nTo build the container, you need to do something like\n\n```shell\nMY_KEY=$(cat gitlab-ci-runner-key)\ndocker build --build-arg SSH_PRIVATE_KEY=\"$MY_KEY\" --tag YOUR_TAG_NAME .\n```\n\nA couple of caveats with this approach: the container just caches the gems; the\n`bundle install` step will still (probably) need to run in the CI pipeline, but\nit'll be a no-op if `Gemfile.lock` hasn't changed. You'll never be worse off\n(time-wise) than if you're installing from scratch, because only the deps which\nhave changed in the lock file will be downloaded. But over time, the container\nmay take longer to run as the list of pre-installed vs actually required\npackages diverges.\n\n:::tip\n\nI did try a similar approach that used `bundle cache` to pull all the deps into\na `vendor/cache` folder and then copy _that_ across into the new image, but I\nhad weird permissions errors that I didn't have the time to figure out. If\nyou've got tips on whether that's a more \"bundler-y\" way to do things then\n[hit me up](mailto:ben@benswift.me).\n\n:::\n\nI want to give a shoutout to Jan Akerman who wrote\n[a helpful blog post](https://janakerman.co.uk/docker-git-clone/) which got me\nstarted---and some of the Dockerfile is taken from that post.\n",
  "createdAt": "2026-05-13T23:14:50.184Z",
  "description": "A multi-stage Dockerfile that pre-installs Ruby gems to cut GitLab CI build times for Jekyll sites by up to 90%.",
  "path": "/blog/2021/10/21/cutting-ruby-ci-pipeline-times-with-pre-installed-bundles",
  "publishedAt": "2021-10-21T00:00:00.000Z",
  "site": "at://did:plc:tevykrhi4kibtsipzci76d76/site.standard.publication/self",
  "tags": [
    "dev"
  ],
  "textContent": "A multi-stage Dockerfile that pre-installs Ruby gems to cut GitLab CI build times for Jekyll sites by up to 90%.",
  "title": "Cutting ruby CI pipeline times with pre-installed bundles"
}