Skip to content

Getting Started ​

The cache server is available as a Docker image and can be deployed via Docker Compose or Kubernetes.

1. Deployment ​

Docker ​

docker-compose.yml
yaml
services:
  cache-server:
    image: ghcr.io/falcondev-oss/github-actions-cache-server
    ports:
      - '3000:3000'
    environment:
      API_BASE_URL: http://localhost:3000
      STORAGE_DRIVER: filesystem
      STORAGE_FILESYSTEM_PATH: /data/cache
      DB_DRIVER: sqlite
      DB_SQLITE_PATH: /data/cache-server.db
    volumes:
      - cache-data:/data

volumes:
  cache-data:

The image is a single static binary on 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 ​

You can deploy the cache server in Kubernetes using the Helm chart hosted in a OCI repository.

Prerequisites ​

  • Helm version 3.8.0 or later (required for OCI support)
  • A running Kubernetes cluster

Steps ​


1. Install the Helm chart ​
bash
helm install <release-name> oci://ghcr.io/falcondev-oss/charts/github-actions-cache-server

Replace <release-name> with your desired release name (e.g., cache-server). This will deploy the cache server with all default values.

2. Verify the deployment ​
bash
kubectl get deployments
kubectl get svc

Ensure the deployment <release-name>-github-actions-cache-server is running and the service is accessible.

Customization ​

To customize the deployment, you can override the default values by creating a values.yaml file.

For all possible configuration options, refer to the values.yaml file.

For more details on customizing Helm charts, see the Customizing the Chart Before Installing.

Then install the chart with your custom values:

bash
helm install <release-name> oci://ghcr.io/falcondev-oss/charts/github-actions-cache-server -f values.yaml

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:

values.yaml
yaml
lifecycle:
  preStop:
    sleep:
      seconds: 30

terminationGracePeriodSeconds: 60

Environment Variables ​

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.

API_BASE_URL ​

  • Required

  • Example: http://localhost:3000

The base URL of your cache server. This needs to be accessible by your runners as it is used for cache uploads and downloads.

WARNING

Make sure the actions runner can reach the cache server on this url!

STORAGE_DRIVER ​

  • Default: filesystem

The storage driver to use for storing cache data. For more information, see Storage Drivers.

DB_DRIVER ​

  • Default sqlite

The database driver to use for storing cache metadata. For more information, see Database Drivers.

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.

WARNING

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, unless 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 limit.

WARNING

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.

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).

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).
  • GitHub Enterprise Cloud with a customized issuer, where the issuer becomes https://token.actions.githubusercontent.com/<enterpriseSlug>. Nothing else is needed: the JWKS stays at the default host, and discovery finds it there.

ACTIONS_TOKEN_JWKS_URL ​

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.

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:

PeriodJob
5 minutesDelete uploads without activity for over a minute
10 minutesDelete parts of merged entries, and storage locations without an entry
1 hourReset stalled merges and expired leases
24 hoursDelete entries older than CACHE_CLEANUP_OLDER_THAN_DAYS, and orphaned storage

PORT ​

  • Default: 3000

The port the server should listen on.

HOST ​

  • Default: all interfaces

The interface the server should listen on.

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 unless 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.

DEBUG ​

  • Optional

Any value enables debug logging.

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://<your-cache-server-url>/management-api/_docs.

DELETE /management-api/storage-locations/{id} answers 409 while the storage location is being downloaded.

2. Self-Hosted Runner Setup ​

Set the environment variable ACTIONS_RESULTS_URL on your runner to the Cache Server API URL.

WARNING

Ensure ACTIONS_RESULTS_URL ends with a trailing slash.

Because the runner does not allow setting the ACTIONS_RESULTS_URL yourself, we need to patch the runner binary/source to allow configuring it.

You can patch the runner binary yourself or use our forked runner image that has its source code modified.

We provide a forked runner image that has the source code modified to allow overriding ACTIONS_RESULTS_URL by setting CUSTOM_ACTIONS_RESULTS_URL.

Auto-update is handled for you

Recent forked-runner images skip runner self-update while CUSTOM_ACTIONS_RESULTS_URL is set, so a server-triggered update can't silently overwrite the patched binary and revert you to GitHub's central cache. You still need to update manually, see Keep the runner up to date below.

Binary Patch ​

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
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
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
bash
[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

This patch prevents the runner from overwriting your custom ACTIONS_RESULTS_URL.

Disable runner auto-update

By default the runner self-updates when GitHub pushes a new version, which restores the stock binary and silently reverts you to GitHub's central cache. There is no error, caching just stops. A binary patch does not survive that. Register the runner with config.sh --disableupdate (or set disableUpdate: true with ARC) to stop auto-updates; it works on both github.com and GitHub Enterprise Server. The forked runner skips self-update automatically while CUSTOM_ACTIONS_RESULTS_URL is set.

Keep the runner up to date ​

Whichever approach you use, GitHub still requires self-hosted runners to stay current. If a runner falls more than ~30 days behind the latest release, or below GitHub's enforced minimum version, GitHub stops queuing jobs to it. So you must update on your own schedule: pull the latest forked-runner image (or rebuild your patched image) and re-deploy periodically. Freezing a runner forever is not an option.

For more information, see How it works.

INFO

It is recommended to install zstd on your runners for faster compression and decompression.

3. Usage ​

There is no need to change any of your workflows! 🔥

If you've set up your self-hosted runners correctly, they will automatically use the cache server for caching.

GitHub Enterprise Server (GHES) ​

GHES is supported on a best-effort basis. The cache server implements the v2 cache protocol only, and GHES hard-codes a few values differently from github.com, so a GHES deployment needs the following on top of the standard setup:

  1. The v2 cache service must be enabled on your instance. The runner only talks to ACTIONS_RESULTS_URL (and therefore 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, so caching will not work. You can confirm v2 is active by checking for ACTIONS_CACHE_SERVICE_V2 in the runner's environment.

    WARNING

    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, so set 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 at the right endpoint. Without a matching issuer, every request is rejected with 401 Invalid token.

  3. Point Results passthrough at your instance. Set DEFAULT_ACTIONS_RESULTS_URL to your instance's Results host (e.g. https://results-receiver.actions.<your-ghes-host>). Otherwise artifact uploads/downloads and other passthrough requests are forwarded to github.com and fail.