GitLab Pages on a custom runner: faster static site builds without burning compute minutes
A GitLab Pages site is the artifact of one CI job. Mark the job with pages: true (or pages.publish for a custom output folder), add tags that match your own runner, and GitLab deploys whatever the job publishes. Running that job on a bigger self-hosted runner shortens long Hugo, Jekyll or JavaScript builds and keeps them off your GitLab.com compute minutes.
Reasons to build Pages on your own runner
On GitLab.com, untagged jobs land on the saas-linux-small-amd64 runner: 2 vCPUs and 8 GB of RAM. A documentation site with 20,000 pages, a Hugo site that resizes thousands of images, or a Next.js static export can take 15 to 40 minutes on that machine. Each of those minutes comes out of your namespace's compute quota, and the Free tier gets 400 per month. A handful of pushes a day can drain that quota by mid-month.
Jobs on your own project or group runner do not use compute minutes. You can also give that runner 16 cores, fast NVMe disk and a warm cache, which helps Hugo and Webpack more than anything you change in the site config. GitLab Pages itself stays free on every tier; only the build moves.
The Pages deployment flow
GitLab treats a job as a Pages job in one of three ways:
- The job sets
pages: true. GitLab publishes thepublicdirectory. - The job sets a
pages:hash, for examplepages: { publish: dist }. GitLab publishes that directory. - You name the job
pages. This still works, and GitLab has deprecated it along with the old top-levelpublishkeyword.
GitLab 17.9 moved publish under pages. Since 17.10, GitLab appends the pages.publish path (or public if you set none) to artifacts:paths for you, so you no longer repeat it. On older self-managed instances, keep an explicit artifacts: paths: [public]. After the job succeeds, GitLab runs an internal pages:deploy job that takes the artifact and serves it at https://<namespace>.gitlab.io/<project>, or at a unique domain, which new projects get by default.
The build job runs on any runner your project can use. The deploy step runs inside GitLab, so your runner needs no network access to GitLab Pages.
Set up a runner for Pages builds
Create a project or group runner with a tag such as pages-builder, then install and register it with the glrt- token. The GitLab custom runner guide walks through installation, executors and config.toml. A Docker executor works well for Pages because each static site generator ships an image:
sudo gitlab-runner register \
--non-interactive \
--url "https://gitlab.com/" \
--token "$RUNNER_TOKEN" \
--executor "docker" \
--docker-image node:22 \
--description "pages-builder-16c"
Set the tags on the runner record in GitLab (Settings > CI/CD > Runners), since register does not accept --tag-list with authentication tokens. For a site that takes 20 minutes on 2 vCPUs, an 8 to 16 vCPU runner with 32 GB of RAM is a reasonable starting point. Hugo uses every core you give it; Jekyll uses one, so a Jekyll site gains more from caching than from cores.
Write the Pages job
A JavaScript site (Astro, Vite, Docusaurus, Next.js static export) that builds into dist:
deploy-pages:
stage: deploy
image: node:22
tags: [pages-builder]
script:
- npm ci
- npm run build
pages:
publish: dist
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
A Hugo site, which writes to public/ by default, based on the official GitLab Pages Hugo template:
create-pages:
stage: deploy
image: hugomods/hugo:exts
tags: [pages-builder]
variables:
HUGO_ENV: production
script:
- hugo --minify
pages: true
rules:
- if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH
Pin the image tag to a Hugo version in production so an upstream release cannot break your build. If you want the tests to run on GitLab's runners and only the heavy build on yours, add tags to the Pages job alone.
Cache dependencies and build output
Most slow Pages builds spend their time downloading packages and regenerating images. Cache both. For npm, cache the npm download cache keyed on the lockfile:
deploy-pages:
image: node:22
tags: [pages-builder]
cache:
key:
files: [package-lock.json]
paths: [.npm/]
script:
- npm ci --cache .npm --prefer-offline
- npm run build
pages:
publish: dist
Caching .npm/ is safer than caching node_modules/, because npm ci deletes node_modules on every run. For Hugo, point the module and file cache into the project directory and cache resources/_gen, which holds processed images:
create-pages:
image: hugomods/hugo:exts
tags: [pages-builder]
variables:
HUGO_CACHEDIR: $CI_PROJECT_DIR/.hugo_cache
cache:
key: hugo-$CI_COMMIT_REF_SLUG
paths:
- .hugo_cache/
- resources/_gen/
script:
- hugo --minify
pages: true
Runners store caches on their own disk by default. With one long-lived Pages runner, the cache stays warm between pipelines. With an autoscaled fleet, configure a distributed cache (S3, GCS or Azure Blob) in [runners.cache], or each fresh VM starts cold.
GitLab.com caps a Pages site at 1 GB, tied to the maximum artifact size. If your build output nears that, move large media to object storage or a CDN and link to it.
Branch previews with path_prefix
On Premium and Ultimate, parallel deployments publish each branch or merge request under its own path. Set pages.path_prefix and an expiry:
pages-preview:
image: node:22
tags: [pages-builder]
script:
- npm ci && npm run build
pages:
publish: dist
path_prefix: "$CI_COMMIT_REF_SLUG"
expire_in: 1 week
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
Parallel deployments expire after 24 hours unless you set expire_in. Previews multiply build volume, which is where a self-hosted runner saves the most compute minutes.
Custom domains
Add a domain under Deploy > Pages > New Domain. For a subdomain such as docs.example.com, create a CNAME to your <namespace>.gitlab.io host. For an apex domain on GitLab.com, create an A record to 35.185.44.232. Both need a TXT record at _gitlab-pages-verification-code.<your-domain> with the value GitLab shows. Turn on Automatic certificate management using Let's Encrypt and GitLab issues and renews the certificate. GitLab.com allows 150 custom domains per Pages site. The runner plays no part in any of this.
Security for Pages runners
A Pages build pulls hundreds of npm packages or Hugo modules from the internet, and any of them can run code during install. Keep the Pages runner away from secrets it does not need:
- Give the Pages runner its own tag and do not reuse it for deploy jobs that hold cloud credentials.
- Mark the runner as protected if only the default branch publishes the site. Merge request previews then need a second, unprotected runner with no secrets attached.
- Use the Docker executor with
privileged = false. Static site generators do not need Docker-in-Docker. - Prefer one job per VM on an autoscaled fleet, so a compromised dependency cannot persist on the host between builds.
Cost trade-offs
Compare the two bills side by side. On GitLab.com, a 25-minute build on the small hosted runner uses 25 compute minutes; ten builds a day exhaust the Free tier's 400 minutes in under two days. The same build on a 16-core runner might take 5 minutes, and your cloud provider bills those 5 minutes of VM time. An always-on VM costs money overnight and at weekends, when your team is offline. An autoscaled runner with idle_count = 0 costs nothing while idle, at the price of a one to two minute boot before each build. A warm distributed cache shortens builds further, and you pay a few cents a month for the bucket.
Troubleshooting
- Pipeline passes, site shows 404. The published folder is empty or wrong. Check that your generator writes to the folder in
pages.publish(orpublic), and browse the job artifacts to confirm. - No
pages:deployjob appears. The job lackspages: true, apageshash or thepagesname, or your instance predates the keyword. On older versions, name the jobpagesand listpublicunderartifacts:paths. - Job stuck in pending. The job's tags do not match an online runner. Compare the tags on the runner record with the job.
- Artifact upload fails with 413 Request Entity Too Large. The output exceeds the artifact limit (1 GB on GitLab.com). Remove source maps and originals of resized images from the output.
- Cache misses on each run. Each job ran on a different autoscaled VM without a distributed cache, or the cache key changed. Check the "Restoring cache" lines in the job log.
FAQ
Does GitLab Pages work with self-hosted runners?
Yes. Any runner your project can use may run the Pages job. GitLab handles the deployment step after the job uploads its artifact.
Do I still need a job called pages?
No. Use pages: true or a pages: hash on a job with any name. The pages job name still works, and GitLab has deprecated it.
Can I publish a folder other than public?
Yes. Set pages.publish: dist (or any path). Since GitLab 17.10 you do not need to repeat it in artifacts:paths.
Can I build GitLab Pages for a repository hosted on GitHub?
Yes, with a GitLab project that mirrors the GitHub repository. See GitLab CI for GitHub and Bitbucket repos.
Managed runners with cirunner.dev
cirunner.dev gives you a large build machine for each Pages job without keeping one running. It provisions a fresh VM per job, registers it with GitLab using a runner token you provide, scales with the queue and destroys the machine afterwards. You pick CPU, RAM, x86_64 or arm64, region, base image and an optional GPU, and pay per compute minute.
GitLab CI and GitHub Actions are the launch platforms, and the service is in early access. Request access.
Skip the runner fleet
cirunner.dev boots a fresh VM for every GitLab CI/CD job, registers it, and destroys it when the job ends. GitHub Actions and GitLab CI are our launch platforms.
Join early access