One of the biggest issues with using a self hosted GitHub runner is that actions that require downloading large amounts of data will bottleneck at the network. actions/cache does not support locally caching objects and artifacts stored on GitHub's servers will require a lot of bandwidth to fetch on every job. We can, however, set up a content proxy using Squid with SSL bumping to locally cache requests from jobs.
This is for Squid 7.6 on macOS with Homebrew.
git clone https://gist.github.com/30c5c96d7575efd1d2a2db5e3def0815.git squid-cache
cd squid-cache
bash deploy.shdeploy.sh builds the patched Squid through a local tap, generates the SSL-bump CA and DH parameters, writes the config and store ID helper, initialises the cache, sets up daily log rotation and starts the service. It is safe to re-run: certificates and a squid.conf you have edited are left alone unless you pass --force-certs / --force-config. See bash deploy.sh --help.
When it finishes it prints the lines to add to each runner's .env:
http_proxy=http://127.0.0.1:3128
https_proxy=http://127.0.0.1:3128
NODE_EXTRA_CA_CERTS=/opt/homebrew/etc/squid/squid-self-signed.pem
SSL_CERT_FILE=/opt/homebrew/etc/squid/ca-bundle.pem
REQUESTS_CA_BUNDLE=/opt/homebrew/etc/squid/ca-bundle.pem
CURL_CA_BUNDLE=/opt/homebrew/etc/squid/ca-bundle.pem
NODE_EXTRA_CA_CERTS gets the bare CA because Node adds it to its own roots. The other three get ca-bundle.pem (public roots plus the bumping CA, written by deploy.sh) because they replace the default bundle. Pointed at the bare CA those clients would trust Squid and nothing else, and would reject every host Squid does not bump — Python in particular fails with CERTIFICATE_VERIFY_FAILED: unable to get local issuer certificate. Rebuild the bundle after brew upgrade ca-certificates.
The rest of this document explains what gets set up and why.
A major challenge is that actions/cache uses Azure storage APIs which makes HTTP range requests. While Squid supports range requests, it is not good at caching them. There is an option, range_offset_limit none which, according to the documentation:
A size of 'none' causes Squid to always fetch the object from the beginning so it may cache the result. (2.0 style)
Three things stop that from working. squid-7.6.patch fixes them, and the formula embeds it so brew install applies it automatically.
1. Azure's x-ms-range header. Azure Storage clients put their byte range in a proprietary x-ms-range header. Squid does not recognise it, so it forwards the header upstream and caches at best the single range the client asked for — and it never learns the request was ranged at all, which also costs it the range_offset_limit exemption in CheckQuickAbortIsReasonable(). The patch rewrites x-ms-range into a standard Range header in clientInterpretRequestHeaders(), before anything else looks at it, so range parsing, range_offset_limit, quick_abort, 206 assembly and upstream Range suppression all work unmodified. Values Squid cannot parse are forwarded untouched for the origin to interpret.
2. The pinned server connection. With ssl_bump, the to-origin connection is pinned to the client connection, and ConnStateData::swanSong() closes it unconditionally — upstream marks that line XXX: Closing pinned conn is too harsh: The Client may want to continue!. With range_offset_limit none that aborts the whole-object download the moment the client has its range, so nothing is ever cached. The patch hands a busy connection over instead of closing it and lets quick_abort decide whether the orphaned transaction should continue, since it already understands range_offset_limit. Plain HTTP is unaffected; this only matters once SSL bumping is in play.
3. Content-Length on a 304. Azure answers revalidation with 304 Not Modified plus Content-Length: 0, describing the empty 304 rather than the stored representation, which RFC 9110 §8.6 forbids. Squid merges it into the cached reply, after which every hit returns 200 with an empty body. The patch keeps the stored Content-Length, which matches the stored body whether or not the origin is compliant.
GitHub releases and Actions cache blobs are fetched with GET requests carrying authentication parameters in the query string. A helper program maps the URL to a store ID so that Squid sees the same object requested with different signatures as one object. github_store_id_helper.py also does some GNOME mirror mapping that you can remove if you only need to cache GitHub objects.
Do not pin a storage account. GitHub moves Actions storage between accounts, so the helper matches any of them:
STRIP_PARAMS = [
re.compile(r'^https://[a-z0-9]+\.blob\.core\.windows\.net(:[0-9]+)?/actions-cache/'),
re.compile(r'^https://release-assets\.githubusercontent\.com(:[0-9]+)?/github-production-release-asset/'),
...
]The Azure rule is scoped to the actions-cache container, whose blob names are content-addressed and immutable; actions-results is upload-only in practice and is deliberately left alone. Release assets are matched on both the current and legacy hostnames.
Two details that will bite you if you write your own helper:
- Squid also sends store ID lookups for CONNECT, where the "URL" is a bare
host:portwith no://. Detecting the concurrency channel ID by looking for://therefore misfires, the helper answers without the channel prefix, and Squid dies withassertion failed: helper.cc: skip == 0 && eom == nullptr. Detect it by shape — a leading integer — and echo it on every reply includingBH. PURGEmust be normalised too. If the helper only answers forGET/HEAD, Squid looks up the un-normalised URL and every purge of a signed URL returns 404.
The configuration is largely inspired from this blog post which details setting up SSL bump for caching large downloads. squid.conf is the full version; here are the important parts.
http_port 127.0.0.1:3128 tcpkeepalive=60,30,3 ssl-bump generate-host-certificates=on dynamic_cert_mem_cache_size=20MB tls-cert=/opt/homebrew/etc/squid/squid-self-signed.crt tls-key=/opt/homebrew/etc/squid/squid-self-signed.key cipher=HIGH:MEDIUM:!LOW:!RC4:!SEED:!IDEA:!3DES:!MD5:!EXP:!PSK:!DSS options=NO_TLSv1,NO_SSLv3 tls-dh=prime256v1:/opt/homebrew/etc/squid/squid-self-signed_dhparam.pem
acl step1 at_step SslBump1
acl github_controlplane ssl::server_name_regex \.actions\.githubusercontent\.com$
acl github_git ssl::server_name github.com
sslcrtd_program /opt/homebrew/opt/squid/libexec/security_file_certgen -s /opt/homebrew/var/logs/ssl_db -M 20MB
sslcrtd_children 5
ssl_bump peek step1
ssl_bump splice github_controlplane
ssl_bump splice github_git
ssl_bump stare all
sslproxy_cert_error deny all
Do not put SINGLE_DH_USE or SINGLE_ECDH_USE in options=: Squid 7 does not have SINGLE_ECDH_USE and OpenSSL 3 does not have SSL_OP_SINGLE_DH_USE, so they log ERROR: Unsupported TLS option. They are no-ops on modern OpenSSL anyway.
Two host groups are spliced because nothing on them is cacheable, so bumping would only add certificate generation and latency to the runner's critical path:
- The Actions control plane — job broker long-polls, token exchange, health checks, result submission. Match it by domain rather than enumerating hostnames; GitHub adds new ones regularly, and everything cacheable lives on
blob.core.windows.netor*.githubusercontent.cominstead. github.com, which serves only/info/refs(no-cache, must-revalidate),git-upload-pack(a POST), and/releases/download/redirects whose signed target changes on every request. The release bytes come fromrelease-assets.githubusercontent.com, a different host that stays bumped and cached.
Splicing only works if every client trusts the public roots as well as the bumping CA — see ca-bundle.pem above.
Note that Squid compiles ACL and refresh_pattern regexes with POSIX regcomp(3). \d and \w are not supported; spell character classes out.
Squid mixes a hash of the pid filename into POSIX shared memory segment names. The result, /squid-XXXX-tls_session_cache.shm, is 33 characters, and Darwin caps shm_open() names at PSHMNAMLEN = 31. Any TLS port therefore aborts at startup:
FATAL: Ipc::Mem::Segment::create failed to shm_open(/squid-3BNP-tls_session_cache.shm): (63) File name too long
Disabling the shared TLS session cache avoids the segment entirely:
sslproxy_session_cache_size 0
The cost is TLS session resumption between the runner and Squid, which is negligible when the runner holds a few long-lived connections. If you would rather keep it, shorten the service name with squid -n sq instead — but then every squid -k ... invocation needs the same flag.
If one request is currently being cached and another request is made to the same object, we want to stall the second request until the first one finished. Usually, this isn't good for performance, but when we are exclusively caching large downloads, this will reduce a lot of redundant downloads.
collapsed_forwarding on
On macOS, the default FD limit (256) is too low.
max_filedescriptors 4096
The helper lets Squid recognise different GET requests as the same object. One process serves many concurrent lookups because the helper answers on a channel ID, so a handful of children is plenty.
store_id_program /opt/homebrew/etc/squid/github_store_id_helper.py
store_id_children 10 startup=2 idle=2 concurrency=10
Each object is limited to 2000 MB and the total cache to 100000 MB; adjust to taste. Actions cache objects run to several hundred megabytes.
maximum_object_size 2000 MB
cache_dir aufs /opt/homebrew/var/cache/squid 100000 16 256
Refresh patterns for the Azure blobs, release assets and action tarballs. The overrides ensure these are cached regardless of the HTTP response, which is fine because the objects have unique immutable IDs in the URL. override-lastmod is as important as override-expire: Azure sends these blobs with a Last-Modified but no Cache-Control, so Squid otherwise falls back to the last-modified factor — freshness of 20% of the object's age — and a cache blob read minutes after it was uploaded goes stale within seconds, making every subsequent range request revalidate. With store_id_program in use these patterns match the store ID — the query-stripped URL — not the original request URL.
refresh_pattern -i ^https://[a-z0-9]+\.blob\.core\.windows\.net(:[0-9]+)?/actions-cache/ 1440 20% 10080 ignore-reload ignore-no-store ignore-private override-expire override-lastmod
refresh_pattern -i ^https://release-assets\.githubusercontent\.com(:[0-9]+)?/github-production-release-asset/ 1440 20% 10080 ignore-reload ignore-no-store ignore-private override-expire override-lastmod
refresh_pattern -i ^https://codeload\.github\.com(:[0-9]+)?/.*/(tar\.gz|zip)/ 1440 20% 10080 ignore-reload ignore-no-store ignore-private override-expire override-lastmod
As detailed above, this requires a patched Squid to work. We want range downloads to cache the entire object.
acl azure_storage dstdomain .blob.core.windows.net
acl github_release_assets dstdomain release-assets.githubusercontent.com
range_offset_limit -1 azure_storage
range_offset_limit -1 github_release_assets
And do not abandon a download just because the client that started it went away — finishing it puts the object in the cache for the next job. Uncachable and private responses are still aborted, because CheckQuickAbortIsReasonable() checks those first.
quick_abort_min -1 KB
acl PURGE method PURGE
That line is not decoration. cache_cf.cc derives Config2.onoff.enable_purge from the number of ACLs mentioning the PURGE method, so without it every PURGE returns 403 — even from localhost. Authorisation comes from the ordinary http_access allow localhost rule; a dedicated http_access allow PURGE localhost placed after http_access deny all would be unreachable.
Homebrew installs none, and macOS newsyslog needs root, so org.squid-cache.logrotate.plist runs squid -k rotate daily from a user LaunchAgent. With logfile_rotate 5 in squid.conf this bounds the log directory at a few tens of megabytes.
git clone/git fetchover HTTPS.git-upload-packis a POST with a dynamically negotiated pack; no proxy can cache it. A git mirror is the only fix.codeload.github.comaction tarballs. Squid will not store a response to a request carryingAuthorizationunless the response isCache-Control: public/must-revalidate(http.cc, RFC 9111 §3.5), and the runner sends a token to codeload even for public actions. Norefresh_patternoption overrides this, andrequest_header_access Authorization denydoes not help either —request->flags.authis set from the client's headers inclientInterpretRequestHeaders(), long before the server-side header filter runs.
squid -k parse # only the four "violates HTTP" warnings are expected
awk '{print $4}' /opt/homebrew/var/logs/access.log | sort | uniq -c | sort -rn | headTCP_HIT / TCP_MEM_HIT should be a large share of requests. If everything is TCP_MISS, the store ID helper is probably returning ERR — check that the hostnames in STRIP_PARAMS still match what your runners actually fetch:
awk '{print $7}' /opt/homebrew/var/logs/access.log | cut -d/ -f3 | sort | uniq -c | sort -rn | head
Is there a docker image somewhere that would make it very easy to be used?