# Benchmarks > Reproducible benchmarks of the self-hosted GitHub Actions cache server: 29 MB image, 0.5 s startup, 3 MiB idle memory, 2.3 GiB/s uploads, 7.9 GiB/s downloads, 34,000 lookups per second. Method, numbers and how to run it yourself. Source: https://gha-cache-server.falcondev.io/benchmarks Speed and a small footprint are why this server exists. Every change to a hot path is benchmarked against the previous image before it merges. ## Results SQLite and filesystem storage on a Ryzen 9 5950X (32 threads), 64 GB RAM, btrfs on NVMe. `node` is the last TypeScript release (9.9.0), `rust` the Rust rewrite. | Metric | node 9.9.0 | rust | | --- | --- | --- | | Image size | 260.1 MiB | 28.9 MiB | | Startup to healthy | 3178.6 ms | 507.8 ms | | Idle memory | 332.9 MiB | 3.0 MiB | | Peak memory | 436.0 MiB | 88.4 MiB | | Total CPU time | 58.56 s | 4.66 s | | Upload (8 × 128 MiB, parallel) | 356 MiB/s | 2371 MiB/s | | First download (merge on read) | 368 MiB/s | 8060 MiB/s | | Later download (merged) | 829 MiB/s | 2312 MiB/s | | Lookups per second (64 concurrent) | 1953 | 33998 | | Lookup p50 | 27.7 ms | 1.7 ms | | Lookup p99 | 87.7 ms | 7.0 ms | | Small save+restore per second (256 KiB, 32 concurrent) | 4 | 1143 | On S3 compatible storage (RustFS on the same host): 1172 MiB/s uploads, 970 MiB/s first downloads, 29,000 lookups per second and 500 small saves per second. ## Method - Each image runs as a container on a fresh Docker volume. Cleanup jobs are off, and token validation is skipped, so the numbers measure the server and not GitHub's key endpoint. - A protocol client sends the same requests as `@actions/cache`: Twirp calls for lookups, reservations and commits, and 32 MiB blocks for uploads. - **Upload:** 8 entries of 128 MiB in parallel, like a build matrix. - **First and later download:** all 8 entries, before and after the merge. - **Lookups:** 5,000 lookups at 64 concurrent. - **Small save+restore:** 300 entries of 256 KiB at 32 concurrent. - CPU time comes from `cpu.stat` and memory from `memory.stat anon` (page cache excluded) in each container's cgroup, sampled every 10 ms. The full write-up, including what each optimization changed, is in [PR #288](https://github.com/falcondev-oss/github-actions-cache-server/pull/288#issuecomment-6365513409). ## Run it yourself The benchmark ships with the repository. It needs Docker with cgroup v2 and the systemd cgroup driver. ```bash git clone https://github.com/falcondev-oss/github-actions-cache-server cd github-actions-cache-server cargo run --release --example benchmark -- \ latest=ghcr.io/falcondev-oss/github-actions-cache-server:latest \ node=ghcr.io/falcondev-oss/github-actions-cache-server:9.9.0 ``` Pass any number of `label=image` pairs. Each image is measured in turn and the report prints one column per label. ## Compared to GitHub's hosted cache GitHub's hosted cache is a remote service. From a self-hosted runner, every restore crosses the internet to GitHub's storage, so it is bounded by your link and your distance to GitHub. Reported numbers: | Setup | Reported speed | 1 GiB restore | | --- | --- | --- | | GitHub cache, self-hosted runner | ~20 MB/s ([actuated, 2023](https://actuated.com/blog/faster-self-hosted-cache)), 5.4 MB/s from Tokyo ([google/wireit#239](https://github.com/google/wireit/issues/239), 2022) | ~54 s | | GitHub cache, GitHub-hosted runner | 231 MB/s ([google/wireit#239](https://github.com/google/wireit/issues/239), 2022) | ~4.6 s | | This server, over 10 GbE | the link's line rate, 1.16 GiB/s | ~0.86 s | | This server, same host | 7.9 GiB/s (measured above) | ~0.13 s | The GitHub figures are user reports, not a controlled benchmark, and the 10 GbE row is the link's limit, not a measurement. To measure your own case, time the same `actions/cache` restore against both backends: the step log prints the size and the speed. --- # Configuration > Every environment variable of the self-hosted GitHub Actions cache server: API_BASE_URL, storage and database drivers, direct downloads, eviction, token validation, cleanup jobs and the management API. Source: https://gha-cache-server.falcondev.io/configuration Every variable is also a command-line flag of the same name in kebab case, e.g. `API_BASE_URL` is `--api-base-url`. A flag overrides its variable. Run the binary with `--help` to list them all. An empty variable counts as unset. The server refuses to start on invalid configuration. A missing required value or a value that does not parse exits with code `2`, a conflicting combination of variables with code `1`. | Variable | Default | | --- | --- | | [`API_BASE_URL`](#api_base_url) | required | | [`PORT`](#port) | `3000` | | [`HOST`](#host) | all interfaces | | [`DEFAULT_ACTIONS_RESULTS_URL`](#default_actions_results_url) | `https://results-receiver.actions.githubusercontent.com` | | [`DEBUG`](#debug) | unset | | [`STORAGE_DRIVER`](#storage_driver) | `filesystem` | | [`DB_DRIVER`](#db_driver) | `sqlite` | | [`ENABLE_DIRECT_DOWNLOADS`](#enable_direct_downloads) | `false` | | [`DIRECT_DOWNLOAD_URL_TTL_SECONDS`](#direct_download_url_ttl_seconds) | `600` | | [`EAGER_MERGE`](#eager_merge) | `false` | | [`MAX_CONCURRENT_MERGES`](#max_concurrent_merges) | `16` | | [`DOWNLOAD_IDLE_TIMEOUT_MS`](#download_idle_timeout_ms) | `300000` | | [`CACHE_CLEANUP_OLDER_THAN_DAYS`](#cache_cleanup_older_than_days) | `90` | | [`CACHE_MAX_SIZE_BYTES`](#cache_max_size_bytes) | unset | | [`CACHE_FILESYSTEM_MAX_USAGE_PERCENT`](#cache_filesystem_max_usage_percent) | `90` | | [`ORPHANED_STORAGE_GRACE_PERIOD_HOURS`](#orphaned_storage_grace_period_hours) | `24` | | [`DISABLE_CLEANUP_JOBS`](#disable_cleanup_jobs) | `false` | | [`ACTIONS_TOKEN_ISSUER`](#actions_token_issuer) | `https://token.actions.githubusercontent.com` | | [`ACTIONS_TOKEN_JWKS_URL`](#actions_token_jwks_url) | discovered from the issuer | | [`ACTIONS_TOKEN_AUDIENCE`](#actions_token_audience) | unset | | [`SKIP_TOKEN_VALIDATION`](#skip_token_validation) | `false` | | [`UNSAFE_ALLOW_DIRECT_DOWNLOADS_WITHOUT_TOKEN_VALIDATION`](#unsafe_allow_direct_downloads_without_token_validation) | `false` | | [`MANAGEMENT_API_KEY`](#management_api_key) | unset | ## Server ### `API_BASE_URL` - Required - Example: `http://cache-server.internal:3000` The URL runners use to reach this server. Upload and download URLs handed to runners start with it, so every runner must be able to reach it. ### `PORT` - Default: `3000` The port the server should listen on. ### `HOST` - Default: all interfaces The interface the server should listen on. ### `DEFAULT_ACTIONS_RESULTS_URL` - Default: `https://results-receiver.actions.githubusercontent.com` The upstream GitHub Actions Results service that requests the cache server does not handle (e.g. artifact uploads/downloads) are transparently forwarded to. On **GitHub Enterprise Server** this must point at your instance's Results host (see [GitHub Enterprise Server](https://gha-cache-server.falcondev.io/guides/github-enterprise-server.md)). ### `DEBUG` - Optional Any value enables debug logging. ## Storage and database ### `STORAGE_DRIVER` - Default: `filesystem` The storage driver to use for storing cache data. For more information, see [storage drivers](https://gha-cache-server.falcondev.io/storage-drivers.md). ### `DB_DRIVER` - Default: `sqlite` The database driver to use for storing cache metadata. For more information, see [database drivers](https://gha-cache-server.falcondev.io/database-drivers.md). ## Downloads and merges ### `ENABLE_DIRECT_DOWNLOADS` - Default: `false` If set to `true`, will send a signed URL to the runner. The runner can then download the cache directly from the storage provider. This is useful if you have a large cache and don't want to proxy the download through the cache server. :::caution The actions runner needs to be able to reach the storage provider directly to use direct downloads. ::: A signed URL is a storage credential, so the server refuses to start with both `ENABLE_DIRECT_DOWNLOADS=true` and [`SKIP_TOKEN_VALIDATION=true`](#skip_token_validation), unless [`UNSAFE_ALLOW_DIRECT_DOWNLOADS_WITHOUT_TOKEN_VALIDATION`](#unsafe_allow_direct_downloads_without_token_validation) is set. The filesystem storage driver has no direct downloads. ### `DIRECT_DOWNLOAD_URL_TTL_SECONDS` - Default: `600` - Range: `1` to `3600` - Helm: `config.directDownloadUrlTtlSeconds` How long a signed direct-download URL stays valid. A shorter lifetime limits the replay of a leaked URL. Clients that fetch the URL some time after receiving it, such as BuildKit, need a longer one. ### `EAGER_MERGE` - Default: `false` Cache data arrives in parts. By default the server merges them into one object on the first download, so with `ENABLE_DIRECT_DOWNLOADS` the first restore of every entry is still proxied through the server. With `EAGER_MERGE=true` the merge starts right after the upload completes, and the first restore can be a direct download. On S3, GCS and Azure Blob Storage the server streams the parts through itself into the merged object, so every upload is read back once. On the filesystem driver the merge is a kernel copy (`copy_file_range`). Eager and lazy merges share the [`MAX_CONCURRENT_MERGES`](#max_concurrent_merges) limit. :::caution Every entry occupies twice its size until the parts cleanup job removes the parts, including entries that are never restored. ::: ### `MAX_CONCURRENT_MERGES` - Default: `16` How many merges one process runs at once, eager or lazy. Past that limit, restores stream the parts and a later download does the merge. ### `DOWNLOAD_IDLE_TIMEOUT_MS` - Default: `300000` Closes a connection whose client has not read a response for this long. `0` disables it. ## Eviction and cleanup ### `CACHE_CLEANUP_OLDER_THAN_DAYS` - Default: `90` The number of days to keep stale cache data and metadata before deleting it. Set to `0` to disable cache cleanup. A negative value fails startup. ### `CACHE_MAX_SIZE_BYTES` - Optional Hard cap, in bytes, on the total size of finalized cache payloads. When set, the server runs **capacity-based eviction** after each completed upload: if the total exceeds this budget, the least-recently-used cache entries are deleted until usage is back down to 90% of the budget. Recency is the last time an entry was downloaded, falling back to when it was last saved. Entries with an in-progress download are never evicted. Applies to any storage driver. Leave unset for unlimited object storage, or use `CACHE_FILESYSTEM_MAX_USAGE_PERCENT` below to cap by disk usage instead. :::tip Eviction runs _after_ an upload finalizes, so the total may briefly exceed the budget under concurrent uploads. Leave headroom for in-flight uploads. ::: ### `CACHE_FILESYSTEM_MAX_USAGE_PERCENT` - Default: `90` - Only applies to the `filesystem` storage driver when `CACHE_MAX_SIZE_BYTES` is **not** set. Maximum percentage of the storage volume's total capacity (including data outside the cache directory) that may be used before capacity-based eviction starts reclaiming space. Ignored when `CACHE_MAX_SIZE_BYTES` is set. ### `ORPHANED_STORAGE_GRACE_PERIOD_HOURS` - Default: `24` How long a storage folder that no database record references must stay untouched before cleanup deletes it. At least `1`. ### `DISABLE_CLEANUP_JOBS` - Default: `false` Disables all cleanup jobs. Each job runs once at startup, then once per period: | Period | Job | | ---------- | ------------------------------------------------------------------------------- | | 5 minutes | Delete uploads without activity for over a minute | | 10 minutes | Delete parts of merged entries, and storage locations without an entry | | 1 hour | Reset stalled merges and expired leases | | 24 hours | Delete entries older than `CACHE_CLEANUP_OLDER_THAN_DAYS`, and orphaned storage | ## Token validation ### `ACTIONS_TOKEN_ISSUER` - Default: `https://token.actions.githubusercontent.com` The OIDC issuer whose signature the cache server verifies on runner tokens. It must match the `iss` claim of a runner's OIDC token exactly. The signing keys are looked up from the issuer's OIDC discovery document (`{issuer}/.well-known/openid-configuration`), so the JWKS location is picked up automatically. Change this if your runner tokens are not issued by `https://token.actions.githubusercontent.com`: - **GitHub Enterprise Server**, where tokens are issued by your own instance (see [GitHub Enterprise Server](https://gha-cache-server.falcondev.io/guides/github-enterprise-server.md)). - **GitHub Enterprise Cloud with a [customized issuer](https://docs.github.com/en/enterprise-cloud@latest/actions/reference/security/oidc#customizing-the-issuer-value-for-an-enterprise)**, where the issuer becomes `https://token.actions.githubusercontent.com/`. Nothing else is needed: the JWKS stays at the default host, and discovery finds it there. ### `ACTIONS_TOKEN_JWKS_URL` - Optional - Default: discovered from [`ACTIONS_TOKEN_ISSUER`](#actions_token_issuer) Explicit JWKS endpoint, skipping OIDC discovery. You should not need this: the URL is normally discovered from the issuer, and if discovery is unavailable the server falls back to `{issuer}/.well-known/jwks`. Set it only if your deployment serves its keys somewhere neither of those finds. A warning naming the fallback URL is logged when discovery fails. ### `ACTIONS_TOKEN_AUDIENCE` - Optional - Example: `https://github.com/my-org` Comma-separated list of accepted `aud` claims. When set, a runner token must carry one of them. Unset skips the audience check. A value that lists no audience (e.g. `,`) fails startup. ### `SKIP_TOKEN_VALIDATION` - Default: `false` Skip verification of the GitHub token used for computing the cache scope. This is useful for testing and development purposes, but should not be used in production. Cannot be combined with [`ENABLE_DIRECT_DOWNLOADS`](#enable_direct_downloads) unless [`UNSAFE_ALLOW_DIRECT_DOWNLOADS_WITHOUT_TOKEN_VALIDATION`](#unsafe_allow_direct_downloads_without_token_validation) is set. ### `UNSAFE_ALLOW_DIRECT_DOWNLOADS_WITHOUT_TOKEN_VALIDATION` - Default: `false` Allows `ENABLE_DIRECT_DOWNLOADS=true` together with `SKIP_TOKEN_VALIDATION=true`. Anyone who can reach the server then gets signed storage URLs for any repository's cache. For local testing only. ## Management API ### `MANAGEMENT_API_KEY` Set this to a random string to enable the management API. Unset or empty disables it. Docs can be found at `https:///management-api/_docs`. `DELETE /management-api/storage-locations/{id}` answers `409` while the storage location is being downloaded. Driver-specific variables are on the [storage driver](https://gha-cache-server.falcondev.io/storage-drivers.md) and [database driver](https://gha-cache-server.falcondev.io/database-drivers.md) pages. --- # Database drivers > Database backends for the cache metadata of the self-hosted GitHub Actions cache server: SQLite, PostgreSQL and MySQL. Source: https://gha-cache-server.falcondev.io/database-drivers The database holds cache keys, versions and upload state, never payloads. Pick it with [`DB_DRIVER`](https://gha-cache-server.falcondev.io/configuration.md#db_driver). | Driver | `DB_DRIVER` | Replicas | Best for | | --- | --- | --- | --- | | [SQLite](https://gha-cache-server.falcondev.io/database-drivers/sqlite.md) | `sqlite` (default) | One | One host, no extra service | | [PostgreSQL](https://gha-cache-server.falcondev.io/database-drivers/postgres.md) | `postgres` | Many | Production, several replicas | | [MySQL](https://gha-cache-server.falcondev.io/database-drivers/mysql.md) | `mysql` | Many | Production, several replicas | The server runs its migrations on startup. --- # MySQL > Use MySQL as the database of the self-hosted GitHub Actions cache server. Recommended for production. Source: https://gha-cache-server.falcondev.io/database-drivers/mysql Driver: `mysql` ## Configuration ### `DB_MYSQL_DATABASE` The name of the MySQL database to use. ### `DB_MYSQL_HOST` The host of the MySQL database. ### `DB_MYSQL_PORT` The port of the MySQL database. ### `DB_MYSQL_USER` The user to authenticate with the MySQL database. ### `DB_MYSQL_PASSWORD` The password to authenticate with the MySQL database. --- # PostgreSQL > Use PostgreSQL as the database of the self-hosted GitHub Actions cache server. Recommended for production. Source: https://gha-cache-server.falcondev.io/database-drivers/postgres Driver: `postgres` ## Configuration ### `DB_POSTGRES_DATABASE` The name of the PostgreSQL database to use. ### `DB_POSTGRES_HOST` The host of the PostgreSQL database. ### `DB_POSTGRES_PORT` The port of the PostgreSQL database. ### `DB_POSTGRES_USER` The user to authenticate with the PostgreSQL database. ### `DB_POSTGRES_PASSWORD` The password to authenticate with the PostgreSQL database. ### `DB_POSTGRES_URL` The full URL to the PostgreSQL database. This is an alternative to the above configuration options. --- # SQLite > Use SQLite as the database of the self-hosted GitHub Actions cache server. The default, no extra service needed. Source: https://gha-cache-server.falcondev.io/database-drivers/sqlite Driver: `sqlite` :::caution It is recommended to use a more robust database driver for production use. ::: ## Configuration ### `DB_SQLITE_PATH` - Default: `.data/sqlite.db` The path to the SQLite database file. This file will be created if it does not exist. The database runs in [WAL mode](https://www.sqlite.org/wal.html) with `synchronous=normal`, so it keeps `-wal` and `-shm` files next to the database file. A power loss or OS crash can drop the last commits, but never corrupts the database. --- # FAQ > Answers about the self-hosted GitHub Actions cache server: local caching without workflow changes, ephemeral runners, actions/cache on-premise, BuildKit type=gha, artifacts, security, GitHub-hosted runners and alternatives. Source: https://gha-cache-server.falcondev.io/faq ## Can I keep the GitHub Actions cache local without changing workflow files? Yes. That is what this server is for. Run it next to your self-hosted runners, point the runners at it with the [forked runner](https://gha-cache-server.falcondev.io/getting-started.md#forked-runner-recommended), and every `actions/cache` step, `setup-*` cache and BuildKit `type=gha` cache uses it. Workflow YAML stays the same. ## Does it work with ephemeral runners? Yes, and that is where it helps most. An ephemeral runner starts empty every job, so a cache on its own disk is gone after one job. The cache server keeps the cache outside the runner, so the next disposable runner restores it at local network speed. See [ephemeral runners with a local cache](https://gha-cache-server.falcondev.io/guides/ephemeral-runners-local-cache.md). ## Does it work with Actions Runner Controller (ARC)? Yes. Swap the runner image in your scale set for the forked image and set one environment variable. The [ARC guide](https://gha-cache-server.falcondev.io/guides/actions-runner-controller.md) has complete Helm values. ## Is this the same as mounting a cache volume into the runner? No. A `hostPath` or persistent volume only helps tools you configure to use it, so you edit workflows to set cache directories, and the cache only exists on that one node. The cache server speaks the `actions/cache` protocol, so existing cache steps use it unchanged, from any node. [Compare the options](https://gha-cache-server.falcondev.io/guides/github-actions-cache-options.md). ## Which actions and tools are supported? Anything built on `@actions/cache` with the v2 cache service: - `actions/cache`, `actions/cache/restore`, `actions/cache/save` (v4 and later) - `actions/setup-node`, `setup-python`, `setup-go`, `setup-java`, `setup-dotnet` and other `setup-*` actions with caching enabled - Docker BuildKit with `cache-from: type=gha` and `cache-to: type=gha` - Third-party actions that use `@actions/cache` internally, such as `Swatinem/rust-cache` ## Does it cache artifacts too? No. `actions/upload-artifact` and `actions/download-artifact` requests are forwarded to GitHub's results service unchanged, so they keep working but are not stored locally. ## Can GitHub-hosted runners use it? No. GitHub-hosted runners use the stock runner, which always overwrites the cache URL. The server is for self-hosted runners. ## Do I need the forked runner? You need a runner that keeps your `ACTIONS_RESULTS_URL`. Use the [forked runner image](https://gha-cache-server.falcondev.io/getting-started.md#forked-runner-recommended), or patch the stock binary yourself with the [binary patch](https://gha-cache-server.falcondev.io/getting-started.md#binary-patch). The fork also skips self-update so a runner update cannot silently undo the patch. ## Is it secure? The server verifies every runner token against GitHub's signing keys, scopes caches per repository and branch like GitHub does, and signs each upload URL. Keep the server on your CI network. `/metrics` has no authentication. As with any shared cache, only let mutually trusted workflows write to it: a malicious pull request on a self-hosted runner can poison caches of its own scope. ## Which storage should I use? On one machine, the default [filesystem driver](https://gha-cache-server.falcondev.io/storage-drivers/file-system.md) with SQLite on local NVMe is the fastest and simplest setup. For several replicas or several runner nodes, use [S3 compatible storage](https://gha-cache-server.falcondev.io/storage-drivers/s3.md) (RustFS, MinIO, R2, AWS) with [PostgreSQL](https://gha-cache-server.falcondev.io/database-drivers/postgres.md). ## How big does the cache get? As big as you allow. Entries unused for 90 days are deleted by default ([`CACHE_CLEANUP_OLDER_THAN_DAYS`](https://gha-cache-server.falcondev.io/configuration.md#cache_cleanup_older_than_days)). Set [`CACHE_MAX_SIZE_BYTES`](https://gha-cache-server.falcondev.io/configuration.md#cache_max_size_bytes) for a hard budget with least-recently-used eviction. On the filesystem driver the server evicts at 90% disk usage by default. There is no 10 GB per-repository limit. ## How fast is it? On one NVMe host: 7.9 GiB/s first download, 2.3 GiB/s parallel upload, 34,000 lookups per second, 3 MiB idle memory. See [benchmarks](https://gha-cache-server.falcondev.io/benchmarks.md) for the method and how to reproduce it. ## Does it work with GitHub Enterprise Server? Yes, on a best-effort basis, when cache service v2 is enabled. See [GitHub Enterprise Server](https://gha-cache-server.falcondev.io/guides/github-enterprise-server.md). ## Is it free? Yes. MIT licensed, no paid tier. Source at [falcondev-oss/github-actions-cache-server](https://github.com/falcondev-oss/github-actions-cache-server). --- # Getting started > Deploy the self-hosted GitHub Actions cache server with Docker Compose or Helm, point your self-hosted runners at it, and actions/cache, setup-* caches and BuildKit type=gha work unchanged. Source: https://gha-cache-server.falcondev.io/getting-started Three steps: run the server, point the runners at it, run a workflow. No workflow file changes. ## 1. Run the server ### Docker Compose ```yaml title="docker-compose.yml" services: cache-server: image: ghcr.io/falcondev-oss/github-actions-cache-server restart: unless-stopped ports: - '3000:3000' environment: API_BASE_URL: http://cache-server.internal:3000 volumes: - cache-data:/app/.data volumes: cache-data: ``` `API_BASE_URL` is the URL your runners use to reach the server. Replace `cache-server.internal` with a hostname or IP every runner can reach. The defaults store payloads on the filesystem and metadata in SQLite, both under `/app/.data`. All options are on the [configuration](https://gha-cache-server.falcondev.io/configuration.md) page. The image is a single static binary on [distroless](https://github.com/GoogleContainerTools/distroless), about 29 MB. It has no shell, so health checks must probe `GET /health` over HTTP instead of running a command inside the container. ### Kubernetes with Helm The chart is published as an OCI artifact. It needs Helm 3.8 or later. ```bash helm install cache-server oci://ghcr.io/falcondev-oss/charts/github-actions-cache-server kubectl get deployments,svc ``` The deployment `cache-server-github-actions-cache-server` serves on port `3000`. Without `config.apiBaseUrl` the chart points runners at the internal service URL, which is what in-cluster runners need. To customize, pass a `values.yaml`. All options are in the chart's [`values.yaml`](https://github.com/falcondev-oss/github-actions-cache-server/blob/dev/install/kubernetes/github-actions-cache-server/values.yaml). ```bash helm install cache-server oci://ghcr.io/falcondev-oss/charts/github-actions-cache-server -f values.yaml ``` For runners managed by Actions Runner Controller, follow the [ARC guide](https://gha-cache-server.falcondev.io/guides/actions-runner-controller.md). #### Graceful shutdown On `SIGTERM` the server stops accepting connections, finishes in-flight requests and running merges, then exits. Ingress controllers learn about a removed pod asynchronously, so add a `preStop` sleep to keep serving until they do. The image has no shell, so use the native sleep handler (Kubernetes 1.30+) and set a grace period longer than the sleep: ```yaml title="values.yaml" lifecycle: preStop: sleep: seconds: 30 terminationGracePeriodSeconds: 60 ``` ## 2. Point your runners at it The stock runner overwrites `ACTIONS_RESULTS_URL` with GitHub's endpoint on every job. You need a runner that keeps your URL. [How it works](https://gha-cache-server.falcondev.io/how-it-works.md) explains why. ### Forked runner (recommended) Use our forked runner image and set `CUSTOM_ACTIONS_RESULTS_URL`: ```yaml image: ghcr.io/falcondev-oss/actions-runner:latest env: CUSTOM_ACTIONS_RESULTS_URL: http://cache-server.internal:3000/ ``` :::caution `CUSTOM_ACTIONS_RESULTS_URL` must end with a trailing slash. ::: - Repository: [falcondev-oss/github-actions-runner](https://github.com/falcondev-oss/github-actions-runner) - Image: `ghcr.io/falcondev-oss/actions-runner:latest` The forked image skips runner self-update while `CUSTOM_ACTIONS_RESULTS_URL` is set, so a server-triggered update cannot silently replace the patched binary and send your caches back to GitHub. You still update it yourself, see [keep the runner up to date](#keep-the-runner-up-to-date). ### Binary patch If you build your own runner image, patch the binary so it overwrites a dummy variable instead of `ACTIONS_RESULTS_URL`. Then set `ACTIONS_RESULTS_URL` (with a trailing slash) on the runner. ```dockerfile title="Dockerfile" FROM ghcr.io/actions/actions-runner:latest # Modify runner binary to retain custom ACTIONS_RESULTS_URL RUN sed -i 's/\x41\x00\x43\x00\x54\x00\x49\x00\x4F\x00\x4E\x00\x53\x00\x5F\x00\x52\x00\x45\x00\x53\x00\x55\x00\x4C\x00\x54\x00\x53\x00\x5F\x00\x55\x00\x52\x00\x4C\x00/\x41\x00\x43\x00\x54\x00\x49\x00\x4F\x00\x4E\x00\x53\x00\x5F\x00\x52\x00\x45\x00\x53\x00\x55\x00\x4C\x00\x54\x00\x53\x00\x5F\x00\x4F\x00\x52\x00\x4C\x00/g' /home/runner/bin/Runner.Worker.dll ``` ```bash title="Linux" sed -i 's/\x41\x00\x43\x00\x54\x00\x49\x00\x4F\x00\x4E\x00\x53\x00\x5F\x00\x52\x00\x45\x00\x53\x00\x55\x00\x4C\x00\x54\x00\x53\x00\x5F\x00\x55\x00\x52\x00\x4C\x00/\x41\x00\x43\x00\x54\x00\x49\x00\x4F\x00\x4E\x00\x53\x00\x5F\x00\x52\x00\x45\x00\x53\x00\x55\x00\x4C\x00\x54\x00\x53\x00\x5F\x00\x4F\x00\x52\x00\x4C\x00/g' /path_to_your_runner/bin/Runner.Worker.dll ``` ```bash title="macOS" gsed -i 's/\x41\x00\x43\x00\x54\x00\x49\x00\x4F\x00\x4E\x00\x53\x00\x5F\x00\x52\x00\x45\x00\x53\x00\x55\x00\x4C\x00\x54\x00\x53\x00\x5F\x00\x55\x00\x52\x00\x4C\x00/\x41\x00\x43\x00\x54\x00\x49\x00\x4F\x00\x4E\x00\x53\x00\x5F\x00\x52\x00\x45\x00\x53\x00\x55\x00\x4C\x00\x54\x00\x53\x00\x5F\x00\x4F\x00\x52\x00\x4C\x00/g' /path_to_your_runner/bin/Runner.Worker.dll ``` ```powershell title="Windows" [byte[]] -split (((Get-Content -Path ./bin/Runner.Worker.dll -Encoding Byte) | ForEach-Object ToString X2) -join '' -Replace '41004300540049004F004E0053005F0052004500530055004C00540053005F00550052004C00','41004300540049004F004E0053005F0052004500530055004C00540053005F004F0052004C00' -Replace '..', '0x$& ') | Set-Content -Path /path_to_your_runner/bin/Runner.Worker.dll -Encoding Byte ``` :::caution[Disable runner auto-update] By default the runner self-updates when GitHub pushes a new version. That restores the stock binary and **silently sends your caches back to GitHub**. There is no error, the local cache just stops being used. Register the runner with `config.sh --disableupdate` to stop auto-updates. It works on github.com and GitHub Enterprise Server. The [forked runner](#forked-runner-recommended) does this for you. ::: ### Keep the runner up to date GitHub requires self-hosted runners to stay current. If a runner falls more than about 30 days behind the latest release, or below GitHub's enforced minimum version, GitHub **stops queuing jobs to it**. Pull the latest forked runner image (or rebuild your patched image) and redeploy on a schedule. :::tip Install `zstd` on your runners. `actions/cache` then compresses and decompresses faster. ::: ## 3. Run a workflow Your workflows already use the server. Every `actions/cache` step, every `setup-*` action with `cache:` set and every BuildKit `type=gha` cache now reads and writes your server. ```yaml title=".github/workflows/ci.yml" jobs: test: runs-on: self-hosted steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: node-version: 24 cache: npm # restored from your cache server - run: npm ci ``` Confirm that requests reach your server with the checks in [troubleshooting](https://gha-cache-server.falcondev.io/troubleshooting.md#confirm-the-runner-uses-your-server). A misconfigured runner falls back to GitHub's cache without an error. ## Next steps - [Ephemeral runners with a local cache](https://gha-cache-server.falcondev.io/guides/ephemeral-runners-local-cache.md): the full architecture for disposable runners - [Actions Runner Controller](https://gha-cache-server.falcondev.io/guides/actions-runner-controller.md): Helm values for ARC runner scale sets - [Storage drivers](https://gha-cache-server.falcondev.io/storage-drivers.md) and [database drivers](https://gha-cache-server.falcondev.io/database-drivers.md): move from one host to S3 and Postgres - [GitHub Enterprise Server](https://gha-cache-server.falcondev.io/guides/github-enterprise-server.md): the three extra settings GHES needs --- # Actions Runner Controller (ARC) with a local cache > Complete tutorial: ephemeral GitHub Actions runners on Kubernetes with Actions Runner Controller (ARC) runner scale sets and a self-hosted cache server on local NVMe. Helm values included, actions/cache workflows stay unchanged. Source: https://gha-cache-server.falcondev.io/guides/actions-runner-controller This tutorial sets up ephemeral runners with [Actions Runner Controller](https://docs.github.com/en/actions/concepts/runners/actions-runner-controller) (ARC) runner scale sets and a cache server on local NVMe. Each job gets a fresh runner pod. The cache survives every pod, and no workflow file changes. ```text GitHub job queue │ ▼ ARC listener (arc-systems) ── scales ──▶ runner pods (arc-runners), one job each │ CUSTOM_ACTIONS_RESULTS_URL ▼ cache server (gha-cache) ──▶ local NVMe volume ``` You need a Kubernetes cluster, Helm 3.8 or later, and a [GitHub App](https://docs.github.com/en/actions/tutorials/use-actions-runner-controller/authenticate-to-the-api) (or a personal access token) that can manage runners. ## 1. Install the cache server on the NVMe node Pin the server to the node with the fast disk and give it a volume on that disk. Any storage class backed by local disks works, e.g. `local-path` on k3s or a local PV. ```yaml title="cache-values.yaml" config: # 200 GiB budget, least recently used entries are evicted first cacheMaxSizeBytes: 214748364800 persistentVolumeClaim: storage: 250Gi storageClassName: local-path nodeSelector: kubernetes.io/hostname: ci-node-1 ``` ```bash helm install cache-server oci://ghcr.io/falcondev-oss/charts/github-actions-cache-server \ --namespace gha-cache --create-namespace \ -f cache-values.yaml ``` The chart points `API_BASE_URL` at its in-cluster service, `http://cache-server-github-actions-cache-server.gha-cache.svc.cluster.local:3000`. Runner pods use the same address. :::tip[Several nodes or replicas] For runners on many nodes, a single cache server on one NVMe node still serves all of them over the network. For several cache server replicas, use [S3 compatible storage](https://gha-cache-server.falcondev.io/storage-drivers/s3.md) and [PostgreSQL](https://gha-cache-server.falcondev.io/database-drivers/postgres.md) instead of the filesystem and SQLite. ::: ## 2. Install the ARC controller ```bash helm install arc \ --namespace arc-systems --create-namespace \ oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set-controller kubectl create namespace arc-runners kubectl -n arc-runners create secret generic arc-auth \ --from-literal=github_app_id="$GITHUB_APP_ID" \ --from-literal=github_app_installation_id="$GITHUB_APP_INSTALLATION_ID" \ --from-file=github_app_private_key=private-key.pem ``` ## 3. Create the runner scale set with the forked runner Replace the runner image with the [forked runner](https://github.com/falcondev-oss/github-actions-runner) and set `CUSTOM_ACTIONS_RESULTS_URL`. Nothing else changes. ```yaml title="runner-values.yaml" githubConfigUrl: https://github.com/YOUR_ORG githubConfigSecret: arc-auth # keep two idle runners so jobs start right away minRunners: 2 maxRunners: 10 template: spec: containers: - name: runner image: ghcr.io/falcondev-oss/actions-runner:latest command: ['/home/runner/run.sh'] env: - name: CUSTOM_ACTIONS_RESULTS_URL # the trailing slash is required value: http://cache-server-github-actions-cache-server.gha-cache.svc.cluster.local:3000/ ``` ```bash helm install ci-ephemeral \ --namespace arc-runners \ -f runner-values.yaml \ oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set ``` The forked image skips runner self-update while `CUSTOM_ACTIONS_RESULTS_URL` is set. Bump the image tag on a schedule, GitHub stops sending jobs to runners that fall about 30 days behind. ## 4. Run your existing workflows The installation name is the `runs-on` label. The workflow below is an ordinary workflow. Its caches now go to your NVMe node. ```yaml title=".github/workflows/ci.yml" jobs: test: runs-on: ci-ephemeral steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: node-version: 24 cache: npm - uses: actions/cache@v4 with: path: .turbo key: turbo-${{ github.sha }} restore-keys: turbo- - run: npm ci - run: npm test ``` ## 5. Confirm the cache is local ```bash kubectl -n gha-cache port-forward svc/cache-server-github-actions-cache-server 3000:3000 & curl -s localhost:3000/metrics | grep -E 'cache_(requests_total|uploads_total|storage_bytes)' ``` `cache_requests_total` goes up with every cache step. If it stays at zero, see [troubleshooting](https://gha-cache-server.falcondev.io/troubleshooting.md). ## Docker builds BuildKit's `type=gha` cache goes through the same server. Run the scale set with `containerMode: { type: dind }` (privileged pods) and use `docker/build-push-action` with `cache-from: type=gha` and `cache-to: type=gha,mode=max`. The action passes the runner's cache URL to BuildKit, so layers land on your NVMe node too. ## Security Ephemeral containers give each job a clean environment, not the isolation of a separate VM. Run untrusted pull requests on separate runners, and only let mutually trusted workflows share a cache: a job can write any entry in its own branch scope. --- # Local GitHub Actions cache for ephemeral self-hosted runners, no workflow changes > Run ephemeral GitHub Actions runners with a persistent local cache. Replace GitHub's hosted cache service with a self-hosted, API-compatible cache server. Works with official actions/cache, setup-node and BuildKit type=gha. Supports ARC, Docker, Kubernetes and local NVMe storage. Source: https://gha-cache-server.falcondev.io/guides/ephemeral-runners-local-cache **Self-hosted GitHub Actions caching without workflow changes.** Run ephemeral GitHub Actions runners with persistent local caching. Replace GitHub's hosted cache service with a self-hosted, API-compatible cache server. Works with official `actions/cache`, `setup-node` and BuildKit GitHub Actions caching. Supports ARC, Docker, Kubernetes and local NVMe storage. ## The problem Ephemeral runners take one job and are destroyed. That is what you want for clean, reproducible builds, but it means: - **The runner's disk is gone after every job.** Package caches, build caches and Docker layers start from zero. - **`actions/cache` uploads to GitHub's cloud.** On github.com, the cache backend is GitHub's hosted service, even for self-hosted runners. Every restore downloads over the internet, and every save uploads. - **A mounted cache volume needs workflow changes.** A `hostPath` volume only helps tools you point at it, so you edit every workflow to set `npm_config_cache`, `GOMODCACHE` and friends. `actions/cache` and `setup-node` caching still go to GitHub. The fix is to **destroy the runner, not the cache**: keep the cache in a service next to the runners that speaks the same API as GitHub's. ## The architecture ```text GitHub Actions (existing workflows, unchanged) │ ▼ ephemeral runners (ARC, Docker or VMs), forked runner image │ CUSTOM_ACTIONS_RESULTS_URL=http://ci-cache.internal:3000/ ▼ GitHub Actions Cache Server (cache API v2) │ ▼ persistent local NVMe (filesystem storage, SQLite metadata) ``` Runners stay disposable. The cache lives on your server and every new runner restores it at local network speed. ## 1. Run the cache server on the NVMe host ```yaml title="docker-compose.yml" services: cache: image: ghcr.io/falcondev-oss/github-actions-cache-server:latest restart: unless-stopped ports: - '3000:3000' environment: API_BASE_URL: http://ci-cache.internal:3000 STORAGE_FILESYSTEM_PATH: /data/cache DB_SQLITE_PATH: /data/cache-server.db # 100 GiB budget, least recently used entries are evicted first CACHE_MAX_SIZE_BYTES: '107374182400' volumes: - /srv/ci-cache:/data ``` ```bash sudo mkdir -p /srv/ci-cache docker compose up -d ``` Replace `ci-cache.internal` with a hostname or private IP every runner can reach. Restrict port 3000 to your CI network. ## 2. Start ephemeral runners with the forked image The stock runner overwrites the cache URL on every job, so use the [forked runner image](https://github.com/falcondev-oss/github-actions-runner). It reads `CUSTOM_ACTIONS_RESULTS_URL` and skips self-update. **On Kubernetes**, use Actions Runner Controller. The [ARC guide](https://gha-cache-server.falcondev.io/guides/actions-runner-controller.md) has the complete Helm values. The only change to a standard scale set: ```yaml title="runner-values.yaml" template: spec: containers: - name: runner image: ghcr.io/falcondev-oss/actions-runner:latest command: ['/home/runner/run.sh'] env: - name: CUSTOM_ACTIONS_RESULTS_URL value: http://ci-cache.internal:3000/ ``` **Without Kubernetes**, start one container per job and register it with `--ephemeral`, e.g. from a systemd unit or a loop, or use the [GitHub Runner Scale Set Client](https://github.com/actions/scaleset). Each container takes one job, exits, and is replaced: ```bash docker run --rm -e RUNNER_TOKEN \ -e CUSTOM_ACTIONS_RESULTS_URL=http://ci-cache.internal:3000/ \ ghcr.io/falcondev-oss/actions-runner:latest \ sh -c './config.sh --url https://github.com/YOUR_ORG --token "$RUNNER_TOKEN" --ephemeral --unattended && ./run.sh' ``` :::caution `CUSTOM_ACTIONS_RESULTS_URL` must end with a trailing slash. ::: ## 3. Keep your workflows Nothing to change. These steps all hit the local server now: ```yaml - uses: actions/setup-node@v6 with: node-version: 24 cache: npm - uses: actions/cache@v4 with: path: ~/.cargo/registry key: cargo-${{ hashFiles('Cargo.lock') }} - uses: docker/build-push-action@v6 with: cache-from: type=gha cache-to: type=gha,mode=max ``` Then [confirm the runners use the server](https://gha-cache-server.falcondev.io/troubleshooting.md#confirm-the-runner-uses-your-server). A misconfigured runner silently falls back to GitHub's cache. ## What you get | Requirement | Result | | --- | --- | | Ephemeral runners, one job each | Yes | | Cache persists across runners | Yes | | Cache stored on local NVMe | Yes | | Official `actions/cache` | Yes | | `setup-*` caching | Yes | | BuildKit `type=gha` | Yes | | Workflow YAML changes | None | | Shared across runner nodes | Yes, over the network | | Stock runner binary | No, use the forked image or the binary patch | | Artifacts stored locally | No, forwarded to GitHub | ## Caveats - **The integration is unofficial.** GitHub does not support changing the cache endpoint. The forked runner tracks upstream releases, and GitHub requires runners to stay within about 30 days of the latest release, so update the image on a schedule. - **Caching still needs cache steps.** The server serves the caches your workflows ask for. A workflow without `actions/cache` or a `setup-*` cache option has nothing to restore. - **Trust boundary.** Only let mutually trusted workflows share a cache. Run untrusted pull requests on separate, VM-isolated runners. ## Related - [Compare the GitHub Actions cache options](https://gha-cache-server.falcondev.io/guides/github-actions-cache-options.md): GitHub's cloud cache, a mounted volume and this server side by side - [Benchmarks](https://gha-cache-server.falcondev.io/benchmarks.md): what local NVMe caching is worth - [Storage drivers](https://gha-cache-server.falcondev.io/storage-drivers.md): move from one disk to S3, RustFS or MinIO --- # GitHub Actions caching: GitHub cloud vs hostPath vs a local cache server > Compare the three ways to cache on self-hosted GitHub Actions runners: GitHub's hosted cache, a mounted hostPath or persistent volume, and a self-hosted cache server compatible with actions/cache. Which works with ephemeral runners, local NVMe and unchanged workflows. Source: https://gha-cache-server.falcondev.io/guides/github-actions-cache-options Self-hosted runners have three ways to keep caches between jobs. They differ in where the bytes live, and in whether your workflows have to change. ## At a glance | | GitHub's hosted cache | Mounted volume (`hostPath`, PVC) | Local cache server | | --- | --- | --- | --- | | Where the cache lives | GitHub's cloud | One node's disk | Your server, any storage | | Workflow changes | None | Every workflow sets cache paths | None | | `actions/cache`, `setup-*` caching | Yes | No, they still go to GitHub | Yes | | BuildKit `type=gha` | Yes | No | Yes | | Ephemeral runners | Yes | Only on the same node | Yes | | Restore speed | Internet bandwidth | Local disk | Local network, 7.9 GiB/s measured | | Shared across nodes | Yes | No | Yes | | Size limit | 10 GB per repository by default | Disk size | Your budget, LRU eviction | | Per-repository isolation | Yes | No, one shared directory | Yes, verified per token | | Concurrent writers | Safe | Depends on the tool | Safe | | Runner binary | Stock | Stock | [Forked or patched](https://gha-cache-server.falcondev.io/getting-started.md#2-point-your-runners-at-it) | ## GitHub's hosted cache The default. `actions/cache` on github.com stores data in GitHub's cloud, also when the runner is self-hosted. It needs no setup and isolates repositories. Every restore is a download over the internet, and every save an upload. For large caches or many jobs per hour, the transfer time can exceed the time the cache saves. On a self-hosted runner in your own datacenter, that traffic leaves your network twice per job. ## A mounted volume Mount a host directory into every runner (`hostPath` in Kubernetes, a bind mount in Docker) and point each tool at it, e.g. `npm_config_cache: /cache/npm`. Access is local disk speed, with nothing to restore or upload. The cost is in the details: - Every workflow sets tool-specific cache paths. `actions/cache` and `setup-*` caching keep going to GitHub. - The cache exists on one node. A job scheduled on another node starts cold. - All repositories share one writable directory. One workflow can read or poison another's cache. - Tools must cope with concurrent jobs writing the same directory. It fits a single machine that runs a few trusted repositories with tools you control. ## A local cache server Run a service that implements GitHub's cache API and point the runners at it. This is what the [GitHub Actions Cache Server](/) does. Workflows keep their `actions/cache` and `setup-*` steps, the bytes stay on your hardware, every node shares the cache, and each repository only sees its own entries. The cost is one extra service and a runner that keeps your cache URL: the [forked runner image](https://github.com/falcondev-oss/github-actions-runner) or a one-line binary patch. ## Which to choose - **GitHub-hosted runners:** GitHub's hosted cache. Nothing else applies. - **Self-hosted runners, small caches, few jobs:** GitHub's hosted cache is fine. - **Ephemeral runners, local caching, unchanged workflows:** a local cache server. See [ephemeral runners with a local cache](https://gha-cache-server.falcondev.io/guides/ephemeral-runners-local-cache.md). - **One persistent machine, one trusted repository, tools you control:** a mounted volume is the least moving parts. The approaches combine. A local cache server for `actions/cache`, plus a persistent BuildKit daemon or registry cache for very large Docker builds, is a common setup. --- # GitHub Enterprise Server > Run the self-hosted GitHub Actions cache server with GitHub Enterprise Server (GHES): enable cache service v2, point token validation at your instance and forward artifacts to your Results host. Source: https://gha-cache-server.falcondev.io/guides/github-enterprise-server GHES is supported on a best-effort basis. The server implements the **v2** cache protocol only, and GHES hard-codes a few values differently from github.com. A GHES deployment needs three settings on top of the [standard setup](https://gha-cache-server.falcondev.io/getting-started.md). ## 1. Enable cache service v2 The runner only talks to `ACTIONS_RESULTS_URL`, and so to the cache server, when GitHub sets `ACTIONS_CACHE_SERVICE_V2=true` on it. If your GHES version does not enable v2, the runner falls back to the legacy v1 protocol, which the cache server does not implement, and caching does not work. Check for `ACTIONS_CACHE_SERVICE_V2` in the runner's environment to confirm v2 is active. :::caution `ACTIONS_CACHE_URL` is the legacy v1 endpoint and is **not** used by the cache server. Setting it has no effect. ::: ## 2. Point token validation at your instance GHES issues runner tokens from your own host. Set [`ACTIONS_TOKEN_ISSUER`](https://gha-cache-server.falcondev.io/configuration.md#actions_token_issuer) to your instance's issuer, the exact `iss` claim of a runner's OIDC token (decode one to confirm the value). The signing keys are then discovered from that issuer. If your instance serves neither a discovery document nor `{issuer}/.well-known/jwks`, point [`ACTIONS_TOKEN_JWKS_URL`](https://gha-cache-server.falcondev.io/configuration.md#actions_token_jwks_url) at the right endpoint. Without a matching issuer, every request is rejected with `401 Invalid token`. ## 3. Forward passthrough requests to your instance Set [`DEFAULT_ACTIONS_RESULTS_URL`](https://gha-cache-server.falcondev.io/configuration.md#default_actions_results_url) to your instance's Results host, e.g. `https://results-receiver.actions.`. Otherwise artifact uploads and downloads and other passthrough requests are forwarded to github.com and fail. ```yaml title="docker-compose.yml" environment: API_BASE_URL: http://cache-server.internal:3000 ACTIONS_TOKEN_ISSUER: https:///_services/token DEFAULT_ACTIONS_RESULTS_URL: https://results-receiver.actions. ``` The issuer above is the common GHES value. Use the `iss` claim from your own token if it differs. --- # How it works > How the self-hosted GitHub Actions cache server stands in for GitHub's cache backend: the cache API v2 it implements, why the runner needs a patch, how uploads, merges and downloads flow, and how caches are scoped. Source: https://gha-cache-server.falcondev.io/how-it-works The server implements the same cache API that GitHub's hosted cache service exposes to runners. `@actions/cache` cannot tell the two apart, so every tool built on it works without changes. ## Request flow ```text workflow step (actions/cache, setup-node, BuildKit type=gha) │ Twirp: GetCacheEntryDownloadURL / CreateCacheEntry / FinalizeCacheEntryUpload ▼ runner ── ACTIONS_RESULTS_URL ──▶ cache server ──▶ storage driver (filesystem, S3, GCS, Azure) │ │ │ PUT /upload/{id} (blocks) └──▶ database driver (SQLite, Postgres, MySQL) │ GET /download/{id} or a signed storage URL ▼ everything else (artifacts, logs) is forwarded to DEFAULT_ACTIONS_RESULTS_URL ``` 1. **Lookup.** The runner asks for a key and a list of restore keys. The server answers with a download URL for the best match in the job's scope, or a miss. 2. **Upload.** On a miss, the step reserves an entry and uploads the archive in blocks to the signed upload URL the server hands out. On object storage the blocks go straight to the bucket through presigned URLs. 3. **Finalize.** The runner commits the upload. The entry becomes visible to the next lookup. 4. **Merge.** Blocks are merged into one object on the first download, or right after upload with [`EAGER_MERGE`](https://gha-cache-server.falcondev.io/configuration.md#eager_merge). 5. **Download.** The server streams the object, or hands out a signed storage URL with [`ENABLE_DIRECT_DOWNLOADS`](https://gha-cache-server.falcondev.io/configuration.md#enable_direct_downloads). Requests the server does not handle, such as artifact uploads, are forwarded to GitHub's results service, so they keep working. ## Cache scoping Every request carries the runner's job token. The server verifies its signature against the issuer's keys, then reads the repository and the cache scopes (the branch refs) the token grants. A job saves to its own branch and restores from its branch and the branches GitHub lets it read, the same rules GitHub's hosted cache applies. A job in one repository never sees another repository's cache. Upload URLs carry a per-upload capability, so knowing an upload id is not enough to write into it. ## Why the runner needs a patch The runner sets `ACTIONS_RESULTS_URL` from the job message on every job, so a value you set yourself is overwritten: ```csharp title="Excerpt from the runner source" var systemConnection = ExecutionContext.Global.Endpoints.Single(x => string.Equals(x.Name, WellKnownServiceEndpointNames.SystemVssConnection, StringComparison.OrdinalIgnoreCase)); Environment["ACTIONS_RUNTIME_URL"] = systemConnection.Url.AbsoluteUri; Environment["ACTIONS_RUNTIME_TOKEN"] = systemConnection.Authorization.Parameters[EndpointAuthorizationParameters.AccessToken]; if (systemConnection.Data.TryGetValue("CacheServerUrl", out var cacheUrl) && !string.IsNullOrEmpty(cacheUrl)) { Environment["ACTIONS_CACHE_URL"] = cacheUrl; } if (systemConnection.Data.TryGetValue("PipelinesServiceUrl", out var pipelinesServiceUrl) && !string.IsNullOrEmpty(pipelinesServiceUrl)) { Environment["ACTIONS_RUNTIME_URL"] = pipelinesServiceUrl; } if (systemConnection.Data.TryGetValue("GenerateIdTokenUrl", out var generateIdTokenUrl) && !string.IsNullOrEmpty(generateIdTokenUrl)) { Environment["ACTIONS_ID_TOKEN_REQUEST_URL"] = generateIdTokenUrl; Environment["ACTIONS_ID_TOKEN_REQUEST_TOKEN"] = systemConnection.Authorization.Parameters[EndpointAuthorizationParameters.AccessToken]; } if (systemConnection.Data.TryGetValue("ResultsServiceUrl", out var resultsUrl) && !string.IsNullOrEmpty(resultsUrl)) { Environment["ACTIONS_RESULTS_URL"] = resultsUrl; } if (ExecutionContext.Global.Variables.GetBoolean("actions_uses_cache_service_v2") ?? false) { Environment["ACTIONS_CACHE_SERVICE_V2"] = bool.TrueString; } ``` The [binary patch](https://gha-cache-server.falcondev.io/getting-started.md#binary-patch) replaces the string `ACTIONS_RESULTS_URL` with `ACTIONS_RESULTS_ORL`, so the runner writes a dummy variable and yours survives. The [forked runner](https://github.com/falcondev-oss/github-actions-runner) does the same in source and reads your URL from `CUSTOM_ACTIONS_RESULTS_URL`. ## Background jobs Each job runs once at startup, then once per period. [`DISABLE_CLEANUP_JOBS`](https://gha-cache-server.falcondev.io/configuration.md#disable_cleanup_jobs) turns them all off. | Period | Job | | --- | --- | | 5 minutes | Delete uploads without activity for over a minute | | 10 minutes | Delete parts of merged entries, and storage locations without an entry | | 1 hour | Reset stalled merges and expired leases | | 24 hours | Delete entries older than [`CACHE_CLEANUP_OLDER_THAN_DAYS`](https://gha-cache-server.falcondev.io/configuration.md#cache_cleanup_older_than_days), and orphaned storage | Capacity-based eviction runs after every finalized upload when [`CACHE_MAX_SIZE_BYTES`](https://gha-cache-server.falcondev.io/configuration.md#cache_max_size_bytes) or [`CACHE_FILESYSTEM_MAX_USAGE_PERCENT`](https://gha-cache-server.falcondev.io/configuration.md#cache_filesystem_max_usage_percent) is exceeded, least recently used first. --- # Metrics > Prometheus metrics for the self-hosted GitHub Actions cache server: cache hits and misses, uploads and storage size. Source: https://gha-cache-server.falcondev.io/metrics Scrape Prometheus metrics to monitor cache performance. The server exposes [Prometheus](https://prometheus.io/) metrics at `GET /metrics` in the standard text exposition format. The endpoint is always enabled and unauthenticated. :::caution `/metrics` has no authentication. Restrict access at the network layer (firewall, Kubernetes `NetworkPolicy`, or a reverse proxy) if the endpoint should not be publicly reachable. ::: ## Scraping Point Prometheus at the server: ```yaml title="prometheus.yml" scrape_configs: - job_name: gha-cache-server static_configs: - targets: ['cache-server:3000'] ``` ## Exposed metrics | Metric | Type | Description | | -------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------- | | `cache_requests_total{result="hit"\|"miss"}` | counter | Cache download-URL lookups by result. A restore-key prefix match counts as a `hit`. | | `cache_uploads_total` | counter | Cache uploads finalized into cache entries. | | `cache_storage_bytes` | gauge | Total size of finalized cache payloads currently in storage. | | `process_*` | gauge | Default process metrics: CPU, resident and virtual memory, open file descriptors, threads, and `process_start_time_seconds` (uptime). | Rates are derived in [PromQL](https://prometheus.io/docs/prometheus/latest/querying/basics/), e.g. requests per minute and hit ratio: ```text # cache lookups per minute rate(cache_requests_total[1m]) * 60 # hit ratio sum(rate(cache_requests_total{result="hit"}[5m])) / sum(rate(cache_requests_total[5m])) ``` When [`CACHE_MAX_SIZE_BYTES`](https://gha-cache-server.falcondev.io/configuration.md#cache_max_size_bytes) is configured, you can chart how full the cache is against the budget: ```text # fraction of the budget in use (0–1) sum(cache_storage_bytes) / ``` :::note `cache_storage_bytes` reflects payload sizes recorded at upload time. ::: ## Scaling Metrics are collected per process. One process serves all requests, so the numbers are exact per replica, and multiple replicas are aggregated by Prometheus: ```text sum(rate(cache_requests_total[5m])) by (result) ``` --- # Storage drivers > Storage backends for the self-hosted GitHub Actions cache server: local filesystem, S3 (AWS, RustFS, MinIO, Cloudflare R2), Google Cloud Storage and Azure Blob Storage. Source: https://gha-cache-server.falcondev.io/storage-drivers The storage driver holds the cache payloads. Pick it with [`STORAGE_DRIVER`](https://gha-cache-server.falcondev.io/configuration.md#storage_driver). | Driver | `STORAGE_DRIVER` | Direct downloads | Best for | | --- | --- | --- | --- | | [File system](https://gha-cache-server.falcondev.io/storage-drivers/file-system.md) | `filesystem` (default) | No | One host with local NVMe, the fastest setup | | [S3, RustFS, MinIO, R2](https://gha-cache-server.falcondev.io/storage-drivers/s3.md) | `s3` | Yes | Several replicas, any S3 compatible store | | [Google Cloud Storage](https://gha-cache-server.falcondev.io/storage-drivers/google-cloud-storage.md) | `gcs` | Yes | Runners on GCP | | [Azure Blob Storage](https://gha-cache-server.falcondev.io/storage-drivers/azure-blob-storage.md) | `azblob` | Yes | Runners on Azure | ## Object storage On S3, GCS and Azure Blob Storage, parts are uploaded through presigned URLs, so the credentials must be able to sign. Without that, uploads fall back to multipart. The server trusts an object it has seen to exist for a minute instead of checking storage on every cache hit. Data deleted behind the server's back (another replica, a bucket lifecycle rule) can still be promised to a runner for up to a minute. --- # Azure Blob Storage > Store self-hosted GitHub Actions caches in Azure Blob Storage, authenticated by connection string or Entra ID. Source: https://gha-cache-server.falcondev.io/storage-drivers/azure-blob-storage Driver: `azblob` This storage driver stores the cache in an Azure Blob Storage container. ## Configuration The driver authenticates either with a connection string (shared key) or with an account name and Entra ID credentials from the environment (workload identity, `AZURE_CLIENT_ID`/`AZURE_TENANT_ID`/`AZURE_CLIENT_SECRET`, or managed identity). Set one or the other, not both. The container must already exist. The server does not create it and fails on startup if it is missing. ### `docker-compose` connection string ```yaml title="docker-compose.yml" services: cache-server: image: ghcr.io/falcondev-oss/github-actions-cache-server:latest ports: - '3000:3000' environment: API_BASE_URL: http://localhost:3000 STORAGE_DRIVER: azblob STORAGE_AZBLOB_CONTAINER: gh-actions-cache STORAGE_AZBLOB_CONNECTION_STRING: DefaultEndpointsProtocol=https;AccountName=mystorageaccount;AccountKey=...;EndpointSuffix=core.windows.net volumes: - cache-data:/app/.data volumes: cache-data: ``` ### `docker-compose` Entra ID This example assumes credentials are provided by the environment, e.g. a [managed identity](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/overview) or `AZURE_CLIENT_ID`, `AZURE_TENANT_ID` and `AZURE_CLIENT_SECRET`. ```yaml title="docker-compose.yml" services: cache-server: image: ghcr.io/falcondev-oss/github-actions-cache-server:latest ports: - '3000:3000' environment: API_BASE_URL: http://localhost:3000 STORAGE_DRIVER: azblob STORAGE_AZBLOB_CONTAINER: gh-actions-cache STORAGE_AZBLOB_ACCOUNT: mystorageaccount volumes: - cache-data:/app/.data volumes: cache-data: ``` ### Helm Set the container and, for Entra ID auth, the account in `values.yaml`: ```yaml title="values.yaml" config: storage: driver: azblob azblob: container: gh-actions-cache account: mystorageaccount # endpoint: https://mystorageaccount.blob.core.windows.net ``` For a connection string, leave `account` and `endpoint` unset and pass `STORAGE_AZBLOB_CONNECTION_STRING` through a Secret referenced by `existingSecret`. Never put it in `values.yaml`. ```yaml title="values.yaml" config: storage: driver: azblob azblob: container: gh-actions-cache existingSecret: cache-server-secrets # contains STORAGE_AZBLOB_CONNECTION_STRING ``` For [AKS workload identity](https://learn.microsoft.com/en-us/azure/aks/workload-identity-overview), annotate the service account with the identity's client ID and label the pods: ```yaml title="values.yaml" serviceAccount: annotations: azure.workload.identity/client-id: podLabels: azure.workload.identity/use: 'true' ``` ### Environment Variables Don't forget to set the `STORAGE_DRIVER` environment variable to `azblob` to use the Azure Blob Storage driver. #### `STORAGE_AZBLOB_CONTAINER` Example: `gh-actions-cache` The name of the blob container used for storage. Always required. #### `STORAGE_AZBLOB_CONNECTION_STRING` Example: `DefaultEndpointsProtocol=https;AccountName=...;AccountKey=...;EndpointSuffix=core.windows.net` Storage account [connection string](https://learn.microsoft.com/en-us/azure/storage/common/storage-configure-connection-string) with a shared key. The account and endpoint are taken from it, so `STORAGE_AZBLOB_ACCOUNT` and `STORAGE_AZBLOB_ENDPOINT` must not be set. #### `STORAGE_AZBLOB_ACCOUNT` Example: `mystorageaccount` The storage account name. Required when not using a connection string. Authenticates with Entra ID credentials from the environment. #### `STORAGE_AZBLOB_ENDPOINT` Example: `https://mystorageaccount.blob.core.windows.net` The Blob service endpoint. Optional, defaults to `https://.blob.core.windows.net`. Only used together with `STORAGE_AZBLOB_ACCOUNT`. ## Minimal permissions All cache objects live under the `gh-actions-cache/` prefix in the container. A connection string carries full account access, so this only applies to Entra ID auth. | Role | Scope | Used for | | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | ---------------------------------------------------------------- | | [Storage Blob Data Contributor](https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/storage#storage-blob-data-contributor) | Container or account | Reading, writing, listing and deleting cache blobs | | [Storage Blob Delegator](https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/storage#storage-blob-delegator) | Account | Signing SAS URLs, only if Contributor is scoped to the container | Part uploads and direct downloads (`ENABLE_DIRECT_DOWNLOADS`) use SAS URLs signed with a [user delegation key](https://learn.microsoft.com/en-us/rest/api/storageservices/create-user-delegation-sas#assign-permissions-with-rbac). Requesting that key is an account-level operation: Storage Blob Data Contributor assigned on the account covers it, but if it is assigned on the container only, also grant Storage Blob Delegator on the account. With a connection string, URLs are signed locally with the shared key. If signing fails, uploads fall back to block uploads through the server's credentials. --- # File system > Store self-hosted GitHub Actions caches on local disk. The default storage driver, no external service needed. Source: https://gha-cache-server.falcondev.io/storage-drivers/file-system Driver: `filesystem` The File System storage driver stores the cache in the local file system of the cache server. This is the default storage driver and is used when no other storage driver is specified. ## Configuration ### `STORAGE_FILESYSTEM_PATH` - Default: `.data/storage/filesystem` The path to the filesystem storage location. The folder will be created if it does not exist. The cache server process needs read and write access to this path (and permission to create it). No other permissions are required for this driver. ## Eager merge The filesystem driver has no direct downloads, so [`EAGER_MERGE`](https://gha-cache-server.falcondev.io/configuration.md#eager_merge) gains little here. It copies the parts into the merged file at upload time and doubles disk use per entry until the parts cleanup job runs. Leave it off unless you want the first restore to read a single file. --- # Google Cloud Storage > Store self-hosted GitHub Actions caches in a Google Cloud Storage bucket, with optional direct downloads from the bucket. Source: https://gha-cache-server.falcondev.io/storage-drivers/google-cloud-storage Driver: `gcs` This storage driver stores the cache in a GCS bucket. ## Configuration ### `docker-compose` GCS bucket ```yaml title="docker-compose.yml" services: cache-server: image: ghcr.io/falcondev-oss/github-actions-cache-server:latest ports: - '3000:3000' environment: API_BASE_URL: http://localhost:3000 STORAGE_DRIVER: gcs STORAGE_GCS_BUCKET: gh-actions-cache # Optional, not required if running on GCP STORAGE_GCS_SERVICE_ACCOUNT_KEY: /gcp/config/application_default_credentials.json volumes: - cache-data:/app/.data # Use host's application default credentials - $HOME/.config/gcloud:/gcp/config:ro volumes: cache-data: ``` ### Environment Variables Don't forget to set the `STORAGE_DRIVER` environment variable to `gcs` to use the GCS storage driver. #### `STORAGE_GCS_BUCKET` Example: `gh-actions-cache` The name of the GCS bucket used for storage. #### `STORAGE_GCS_SERVICE_ACCOUNT_KEY` Example: `/config/auth/serviceaccount.json` Path to the service account key used for authentication. If not set, [Application Default Credentials](https://cloud.google.com/docs/authentication/application-default-credentials) is used. #### `STORAGE_GCS_ENDPOINT` Example: `http://localhost:9000` The API endpoint for GCS. ## Minimal permissions All cache objects live under the `gh-actions-cache/` prefix in the bucket. The service account needs these IAM permissions on the bucket: | Permission | Used for | | ------------------------ | ------------------------------------------- | | `storage.buckets.get` | Startup bucket check | | `storage.objects.get` | Downloading cache entries, existence checks | | `storage.objects.list` | Listing/counting cache objects | | `storage.objects.create` | Uploading cache entries, merging parts | | `storage.objects.delete` | Cache cleanup and abandoned uploads | The predefined [`roles/storage.objectAdmin`](https://cloud.google.com/storage/docs/access-control/iam-roles) role covers all the object permissions but **not** `storage.buckets.get`. Either grant `roles/storage.objectAdmin` together with `roles/storage.legacyBucketReader`, or create a custom role with the five permissions above. Parts are uploaded through signed URLs, and direct downloads (`ENABLE_DIRECT_DOWNLOADS`) hand out signed URLs too. Without a `STORAGE_GCS_SERVICE_ACCOUNT_KEY` (i.e. using Application Default Credentials), signing requires the `iam.serviceAccounts.signBlob` permission (granted by `roles/iam.serviceAccountTokenCreator`). Without it, uploads fall back to multipart and direct downloads fail. This is not needed when a service account key file is provided, since URLs are then signed locally. --- # S3 / RustFS / MinIO / R2 > Store self-hosted GitHub Actions caches in AWS S3, RustFS, MinIO, Cloudflare R2 or any S3 compatible storage, with optional direct downloads from the bucket. Source: https://gha-cache-server.falcondev.io/storage-drivers/s3 Driver: `s3` This storage driver stores the cache in a S3 compatible storage, e.g. AWS S3 or RustFS. ## Configuration ### `docker-compose` RustFS example ```yaml title="docker-compose.yml" services: cache-server: image: ghcr.io/falcondev-oss/github-actions-cache-server:latest ports: - '3000:3000' environment: API_BASE_URL: http://localhost:3000 STORAGE_DRIVER: s3 STORAGE_S3_BUCKET: gh-actions-cache AWS_ACCESS_KEY_ID: access_key AWS_SECRET_ACCESS_KEY: secret_key AWS_ENDPOINT_URL: http://rustfs:9000 volumes: - cache-data:/app/.data rustfs: image: rustfs/rustfs:latest ports: - '9000:9000' - '9001:9001' environment: RUSTFS_ACCESS_KEY: access_key RUSTFS_SECRET_KEY: secret_key volumes: - rustfs-data:/data volumes: cache-data: rustfs-data: ``` ### `docker-compose` AWS S3 example This example assumes that credentials are being provided by the environment, e.g. via an [instance profile](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_switch-role-ec2_instance-profiles.html) or [EKS IRSA](https://docs.aws.amazon.com/eks/latest/userguide/iam-roles-for-service-accounts.html). ```yaml title="docker-compose.yml" services: cache-server: image: ghcr.io/falcondev-oss/github-actions-cache-server:latest ports: - '3000:3000' environment: API_BASE_URL: http://localhost:3000 STORAGE_DRIVER: s3 STORAGE_S3_BUCKET: gh-actions-cache volumes: - cache-data:/app/.data volumes: cache-data: ``` ### Environment Variables The only required S3-related environment variables are `STORAGE_DRIVER: s3` and `STORAGE_S3_BUCKET`. The rest of the environment variables are optional and depend on your S3-compatible storage provider. The server picks up AWS credentials from the environment: `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` (with `AWS_SESSION_TOKEN`), a web identity token (EKS IRSA), ECS task credentials or EC2 instance metadata. Outside of AWS, the access key variables authenticate with S3-compatible storage, as seen in the RustFS example above. Shared credential files and `AWS_PROFILE` are not supported. Common environment variables are listed below. For a full list, see [`AmazonS3Builder::from_env`](https://docs.rs/object_store/latest/object_store/aws/struct.AmazonS3Builder.html#method.from_env). #### `STORAGE_S3_BUCKET` Example: `gh-actions-cache` The name of the S3 bucket used for storage. This environment variable is always required. #### `AWS_REGION` Example: `us-east-1` Defaults to `us-east-1`. This has no effect if you are using a non-AWS S3-compatible storage provider, such as RustFS. #### `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` Example: `AWS_ACCESS_KEY_ID: access_key` `AWS_SECRET_ACCESS_KEY: secret_key` This is the access key/secret key used to authenticate with S3-compatible storage. If required to authenticate with your provider, these should be provided by the provider. #### `AWS_ENDPOINT_URL` Example: `http://rustfs:9000` This is the endpoint URL for the S3-compatible storage. This is only required if you are using a non-AWS S3-compatible storage provider, such as RustFS. #### `STORAGE_S3_SOCKET_TIMEOUT_MS` - Default: `10000` How long a request to S3 may go without making progress before it fails. `0` disables it. ## Minimal IAM permissions The cache server only ever touches objects under the `gh-actions-cache/` prefix in the bucket. These are all the actions it performs: | Action | Used for | | ------------------------- | ---------------------------------------------------- | | `s3:ListBucket` | Startup bucket check, listing/counting cache objects | | `s3:GetObject` | Downloading cache entries, existence checks | | `s3:PutObject` | Uploading cache entries, merging parts | | `s3:AbortMultipartUpload` | Cleaning up failed multipart uploads | | `s3:DeleteObject` | Cache cleanup and abandoned uploads | A minimal policy, scoped to the bucket and prefix (replace `YOUR_BUCKET`): ```json { "Version": "2012-10-17", "Statement": [ { "Sid": "github-actions-cache-server-bucket", "Effect": "Allow", "Action": ["s3:ListBucket"], "Resource": "arn:aws:s3:::YOUR_BUCKET" }, { "Sid": "github-actions-cache-server-objects", "Effect": "Allow", "Action": ["s3:GetObject", "s3:PutObject", "s3:AbortMultipartUpload", "s3:DeleteObject"], "Resource": "arn:aws:s3:::YOUR_BUCKET/gh-actions-cache/*" } ] } ``` Parts are uploaded through presigned URLs, which the server signs with its own credentials. If they can't sign, uploads fall back to multipart. ## Incomplete multipart uploads A server killed mid-upload or mid-merge leaves an incomplete multipart upload that S3 keeps and bills. Add a lifecycle rule that aborts them: ```json { "Rules": [ { "ID": "abort-incomplete-uploads", "Status": "Enabled", "Filter": { "Prefix": "gh-actions-cache/" }, "AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 1 } } ] } ``` --- # Troubleshooting > Confirm your self-hosted runners use the GitHub Actions cache server, and fix the common causes when they do not: missing trailing slash, runner self-update, cache service v1, 401 Invalid token, unreachable API_BASE_URL. Source: https://gha-cache-server.falcondev.io/troubleshooting A misconfigured runner does not fail. It falls back to GitHub's hosted cache, or skips the cache, and the job stays green. So check that requests arrive before you trust the setup. ## Confirm the runner uses your server Run any workflow with a cache step, then check one of these: - **Metrics.** `cache_requests_total` on [`/metrics`](https://gha-cache-server.falcondev.io/metrics.md) goes up with every lookup: ```bash curl -s http://cache-server.internal:3000/metrics | grep cache_requests_total ``` - **Logs.** Start the server with [`DEBUG=1`](https://gha-cache-server.falcondev.io/configuration.md#debug). Every request is logged: ```text Request: POST /twirp/github.actions.results.api.v1.CacheService/GetCacheEntryDownloadURL Response: POST /twirp/github.actions.results.api.v1.CacheService/GetCacheEntryDownloadURL > 200 ``` - **Job log.** On a hit, the `actions/cache` step logs `Cache restored from key: ...`. A restore that takes seconds for a large cache on a local network is your server. GitHub's cache from a self-hosted runner is usually much slower. If none of these show traffic, work through the list below. ## Common causes | Symptom | Cause | Fix | | --- | --- | --- | | No requests reach the server | The stock runner overwrote `ACTIONS_RESULTS_URL` | Use the [forked runner](https://gha-cache-server.falcondev.io/getting-started.md#forked-runner-recommended) or the [binary patch](https://gha-cache-server.falcondev.io/getting-started.md#binary-patch) | | Worked, then stopped without an error | The runner self-updated and restored the stock binary | Use the forked runner, or register with `config.sh --disableupdate` | | Cache steps warn and skip the cache | `CUSTOM_ACTIONS_RESULTS_URL` or `ACTIONS_RESULTS_URL` lacks the trailing slash | Use `http://cache-server.internal:3000/` | | Lookups succeed, uploads or downloads time out | Runners cannot reach `API_BASE_URL` | Set [`API_BASE_URL`](https://gha-cache-server.falcondev.io/configuration.md#api_base_url) to an address the runners can reach | | Every request answers `401 Invalid token` | The token issuer does not match | Set [`ACTIONS_TOKEN_ISSUER`](https://gha-cache-server.falcondev.io/configuration.md#actions_token_issuer), see [GHES](https://gha-cache-server.falcondev.io/guides/github-enterprise-server.md) | | Direct downloads fail | Runners cannot reach the bucket, or the credentials cannot sign URLs | Open the network path, see the [storage driver](https://gha-cache-server.falcondev.io/storage-drivers.md) permissions | | GHES runner never calls the server | Cache service v2 is off, the runner uses the v1 protocol | [Enable v2](https://gha-cache-server.falcondev.io/guides/github-enterprise-server.md#1-enable-cache-service-v2) | | GitHub stops sending jobs to the runner | The runner is more than about 30 days behind | [Update the runner image](https://gha-cache-server.falcondev.io/getting-started.md#keep-the-runner-up-to-date) | | A job has no cache at all | The workflow has no cache step and no `setup-*` with `cache:` | The server only serves caches a workflow asks for | ## Still stuck Open an issue at [falcondev-oss/github-actions-cache-server](https://github.com/falcondev-oss/github-actions-cache-server/issues) with the server version, the drivers, and the debug log of one failing request.