Self-hosted GitHub Actions cache server, and the variable it has to win
A self-hosted GitHub Actions cache server is a service that speaks the same API actions/cache already talks to, so your workflows keep their keys and only the storage behind them moves. The hard part is not the server. It is that the runner rewrites the environment variable pointing at the cache service before every action runs, so pointing a job at your own endpoint takes more than setting a variable.

Teams reach for this for three reasons that have nothing to do with each other. A self-hosted runner in your own network wants the cache next to it rather than across the internet. A repository outgrowing the 10 gigabyte allowance wants storage it controls. And an air-gapped or heavily firewalled environment cannot reach GitHub's cache service at all.
All three are solvable, and all three run into the same mechanism. This page explains what actions/cache actually resolves before it makes a request, which variable a cache server has to control, why the obvious one no longer works, and what you are signing up to operate.
What actions/cache resolves before it makes a request
The toolkit decides two things on every call. First the service version: it is v1 on GitHub Enterprise Server always, and otherwise v2 when the ACTIONS_CACHE_SERVICE_V2 variable is set and v1 when it is not. Then the URL, and this is where the two versions diverge. On v1 it uses ACTIONS_CACHE_URL, falling back to ACTIONS_RESULTS_URL. On v2 it uses ACTIONS_RESULTS_URL and nothing else.
That is the sentence every out-of-date guide gets wrong. Setting ACTIONS_CACHE_URL was the documented trick for years, and on a runner GitHub has moved to cache service v2 it now has no effect whatsoever, because the v2 branch never reads it. Your override is present, correct and ignored, and the cache quietly goes back to GitHub.
The two versions are also different protocols rather than different hosts for one protocol. Cache service v2 is a twirp API, and a server that implements v2 will not answer a v1 runner. Most current implementations target v2 only, which means a GitHub Enterprise Server instance that has not enabled v2 cannot use them at all.
| Cache service v1 | Cache service v2 | |
|---|---|---|
| Selected when | GHES always, or ACTIONS_CACHE_SERVICE_V2 is unset | ACTIONS_CACHE_SERVICE_V2 is set on a non-GHES runner |
| URL read from | ACTIONS_CACHE_URL, falling back to ACTIONS_RESULTS_URL | ACTIONS_RESULTS_URL only |
isFeatureAvailable() checks | ACTIONS_CACHE_URL is non-empty | ACTIONS_RESULTS_URL is non-empty |
| Protocol | The legacy artifact cache HTTP API | A twirp API against the results receiver |
What setting ACTIONS_CACHE_URL does | Redirects the cache | Nothing at all |
The message that names a network problem and proves an environment one
This is the line people paste when a cache server is not working, and its plain reading is wrong in a way that sends the investigation to the firewall instead of the environment.
Successfully set up Go version 1.23
Warning: The runner was not able to contact the cache service. Caching will be skippedOnce you read it that way, the causes are a short list. The runner is executing somewhere the cache variables were never exported, which is the usual answer inside a container job or an Actions Runner Controller pod where the step runs in a different environment from the runner process. Or the runner is on cache service v2 while your override set the v1 variable, so the v2 branch found nothing. Or the variable is genuinely unset, which is what happens on a runner that never registered with a cache-capable service.
The diagnostic is one step, and it is faster than reading firewall logs. Print the two variables and the version flag in a step before the cache action runs. If ACTIONS_RESULTS_URL is empty on a v2 runner, no amount of network debugging will help, and if it is set to GitHub's own host then your override lost a race you have not noticed yet.
- name: Show what the cache actions will resolve
run: |
echo "v2 flag: ${ACTIONS_CACHE_SERVICE_V2:-unset}"
echo "results: ${ACTIONS_RESULTS_URL:-unset}"
echo "cache: ${ACTIONS_CACHE_URL:-unset}"Why the override loses, and what the implementations do about it
The runner does not read your variable and leave it alone. Before it launches a JavaScript action it pulls service endpoints out of the job's system connection and writes them into the action's environment, overwriting whatever was there. ACTIONS_RESULTS_URL is one of those, and so is ACTIONS_CACHE_URL, and so is the flag that selects v2. Setting them in your workflow, in the runner's .env or in the shell that launched the service loses every time, because the overwrite happens later than all of them.
if (systemConnection.Data.TryGetValue("CacheServerUrl", out var cacheUrl) && !string.IsNullOrEmpty(cacheUrl))
{
Environment["ACTIONS_CACHE_URL"] = cacheUrl;
}
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;
}So every working implementation changes the runner rather than the environment. The established project in this space, falcondev-oss/github-actions-cache-server, offers two routes. The first is a forked runner image that reads CUSTOM_ACTIONS_RESULTS_URL and keeps it, which also skips runner self-update while that variable is set. The second is a byte patch on Runner.Worker.dll that renames the string ACTIONS_RESULTS_URL to a near-identical name, so the overwrite lands on a variable nobody reads and yours survives.
Both routes carry the same operational trap and it is worth knowing before you choose either. A stock runner self-updates when GitHub ships a new version, and that restores the unpatched binary. Nothing fails when it does. The override stops working, caching silently reverts to GitHub, and the only symptom is that your builds got slower. Registering with --disableupdate, or the equivalent setting under Actions Runner Controller, is the part of this setup that people skip.
That does not let you freeze the runner, though, and the two facts pull in opposite directions. GitHub documents that a runner registered with --disableupdate must be updated within 30 days of a new release, and that the Actions service stops queuing jobs to one that is not, sooner if the update is a critical security fix. So you have to update on your own schedule: pull the new forked image or rebuild the patched one, and redeploy. A cache server turns runner updates from something that happens to you into something you own.
What you are actually operating
The server itself is a small service with a storage driver and a metadata database, and the reference implementation ships as a container with filesystem, S3-compatible or Google Cloud Storage drivers and SQLite, PostgreSQL or MySQL behind it. That part is undemanding. The parts that need thought are the ones a hosted cache does for you silently.
Retention and eviction become yours. GitHub removes entries unused for seven days and evicts oldest-first at 10 gigabytes; your server needs an equivalent policy or it fills a disk. Authentication becomes yours: the reference implementation verifies the runner's OIDC token against the issuer's discovery document, which means an enterprise with a custom issuer has a configuration step and an air-gapped deployment has a real problem. And requests the cache server does not handle, such as artifact uploads, have to be forwarded to the real results service, because artifacts never move with the cache.
Before you build any of it, price the alternative honestly, because several runner vendors sell exactly this as a feature of the runner. A colocated or transparent cache that intercepts actions/cache without a workflow change is what Blacksmith, Ubicloud and RunsOn all ship, in the last case backed by an S3 bucket in your own VPC. RunsOn vs GitHub hosted runners covers what that arrangement costs, and self-hosted versus managed runners is the wider version of the same decision.
What to check, in order
- Print
ACTIONS_RESULTS_URL,ACTIONS_CACHE_URLandACTIONS_CACHE_SERVICE_V2in a step before the cache action, and read them before you look at anything else. - If the v2 flag is set and you overrode
ACTIONS_CACHE_URL, that is the bug: the v2 branch never reads it. - If
ACTIONS_RESULTS_URLpoints at a GitHub host despite your override, the runner overwrote it, and the fix is a patched or forked runner rather than another place to set the variable. - Confirm the runner is not self-updating, then diarise the update. An update restores the stock binary and caching silently returns to GitHub, but a runner more than 30 days behind stops being queued jobs at all.
- Check that the URL ends in a trailing slash, which the reference implementation requires, and that the runner can actually reach it from wherever the job executes rather than from the host.
- Decide who owns eviction before the first disk fills, because nothing in this stack does it for you by default.
Frequently asked questions
How does a self-hosted GitHub Actions cache server intercept actions/cache?
actions/cache reads ACTIONS_RESULTS_URL on cache service v2 and ACTIONS_CACHE_URL on v1, and talks to whatever is there using the same protocol GitHub does. Your workflow keeps its paths, keys and restore-keys, because the only thing that changes is which host answers.Why does setting ACTIONS_CACHE_URL have no effect?
ACTIONS_RESULTS_URL only and never looks at ACTIONS_CACHE_URL. Your override is present and ignored, and caching continues against GitHub. Check ACTIONS_CACHE_SERVICE_V2 in the job environment to see which branch you are on.Why do cache servers need a patched or forked runner?
ACTIONS_RESULTS_URL, ACTIONS_CACHE_URL and the v2 flag into the action's environment, after anything you could have set. The two published workarounds are a forked runner that honours a separate variable, and a byte patch that renames the string the runner assigns to.What does "The runner was not able to contact the cache service" actually mean?
ACTIONS_RESULTS_URL or ACTIONS_CACHE_URL has a value and makes no request at all. Look at the job environment, and in particular at container jobs and Actions Runner Controller pods where the step runs somewhere the variables were never exported.Do artifacts go to a self-hosted cache server too?
Related guides
References
- actions/toolkit: cache service version and URL resolution in packages/cache/src/internal/config.ts (verified 2026-09-21)
- actions/runner: the service endpoint assignment in NodeScriptActionHandler.cs (verified 2026-09-21)
- GitHub Actions Cache Server: deployment, runner patching and configuration (verified 2026-09-21)
- falcondev-oss/github-actions-cache-server#106, the warning quoted on this page (verified 2026-09-21)
- GitHub Docs: self-hosted runner automatic updates, --disableupdate and the 30-day rule (verified 2026-09-21)
- GitHub Actions documentation