docker-registry-cache-proxy¶
Behaviors
Point both rootful and rootless Docker at a pull-through registry cache whose TLS is terminated with a self-signed CA — and get the trust chain in place before the daemon starts.
Problem¶
You run a caching pull-through registry proxy (e.g. a registry:2 in
proxy mode, or Harbor/Zot) so your hosts don't re-download the same Docker
Hub layers over and over. The proxy serves HTTPS with a private CA. You now
need every Docker daemon on your fleet to:
- route registry pulls through the proxy, and
- trust the proxy's CA — including for the upstream hostnames the daemon thinks it is contacting.
Naively dropping the CA into the system trust store (or into a oneshot that runs "sometime during boot") does not reliably work. Two things bite you.
The traps¶
1. certs.d/ is keyed by the registry the daemon addresses, not by the
proxy. Docker looks for a per-registry CA under
/etc/docker/certs.d/<host:port>/ca.crt. When you configure Docker Hub through
a proxy, the daemon still resolves Hub as two distinct endpoints:
registry-1.docker.io— the image/data endpointregistry.docker.io— the auth/index endpoint
So the CA must be installed in three certs.d/ directories: the proxy's
own host:port, and both registry-1.docker.io and registry.docker.io.
Miss either Hub hostname and pulls fail with x509 errors even though the proxy
cert is "trusted" everywhere else.
2. The CA must be trusted before docker.service starts. If Docker comes
up first, its first pull fails and the failure can stick. The setup unit is
therefore ordered Before=docker.service and pulled in by
WantedBy=docker.service.
3. The network may not be up when the setup unit runs. Even with
After=network-online.target, the CA host can be briefly unreachable at boot.
The unit fetches ca.crt in a retry loop (default 30 attempts, 2s apart)
and only fails after exhausting them, with Restart=on-failure as a backstop.
What the module does¶
- Sets
HTTP_PROXY/HTTPS_PROXY(andNO_PROXY=localhost,127.0.0.1) on the Docker service, plusvirtualisation.docker.daemon.settings.proxies. - Points
SSL_CERT_FILEat a merged bundle (system CAs + the cache CA). - Runs
docker-ca-setup(oneshot, before Docker): downloads the CA with the retry loop and installs it into all threecerts.d/directories plus the merged bundle. - Optionally repeats the whole thing for rootless Docker as a
systemd.userservice, writing~/.config/docker/certs.d/...and patching~/.docker/config.jsonproxies (backing up any existing file first).
Usage¶
{
imports = [ ./docker-registry-cache-proxy ];
behaviors.docker-cache = {
enable = true;
cacheUrl = "https://docker-cache.example.com"; # your pull-through cache
# proxyPort = 3128; # HTTP proxy port on the cache host (default)
# caCertPath = "/ca.crt"; # path on cacheUrl that serves the CA
# caCertSha256 = null; # pin the CA by hash (recommended, see below)
# maxAttempts = 30; # CA-download retries before failing
# enableRootless = true; # also wire rootless per-user Docker
};
}
Options¶
| Option | Default | Meaning |
|---|---|---|
enable |
false |
Turn the behavior on. |
cacheUrl |
(required) | Base URL of the pull-through cache; its CA is fetched from here. |
proxyPort |
3128 |
Port on the cache host speaking the proxy/registry protocol. |
caCertPath |
/ca.crt |
Path, relative to cacheUrl, that serves the CA cert. |
caCertSha256 |
null |
Optional lowercase-hex SHA-256 of the CA PEM; when set, the download is verified and install fails closed on mismatch. |
maxAttempts |
30 |
CA-download retries before the oneshot fails. |
enableRootless |
true |
Also install CA + proxy config for rootless (per-user) Docker. |
Caveats¶
- The
caCertPathendpoint must serve the raw PEM CA to an unauthenticated client at boot — the retry loop has no credentials. - The rootless variant assumes a per-user systemd session and writes into the
user's
$HOME. It patches~/.docker/config.json; a pre-existing file is copied toconfig.json.bakfirst. - The daemon proxy settings send all Docker HTTP(S) traffic through the proxy
except
localhost,127.0.0.1. If you need other no-proxy hosts, extend the module.
Security notes¶
- This is the trust bootstrap for your whole registry chain. The fetched CA
is installed as a trusted anchor for the real Docker Hub hostnames
(
registry-1.docker.io,registry.docker.io), so whoever controls that CA can serve any image layer to every host. Treat the CA and the cache host as fleet-critical, and trust a self-signed CA fleet-wide only if you accept that. - The boot-time CA fetch is only as trustworthy as its transport. If you set
cacheUrltohttp://, or tohttps://with a serving cert nothing here validates, an on-path attacker can hand back their own CA and Docker will then trust attacker-signed certs for Docker Hub. Serve the CA over TLS with a publicly-trusted serving cert and/or pin it withcaCertSha256(compute it withsha256sum ca.crt); the pin makes the module fail closed on any mismatch. LeavingcaCertSha256 = nullkeeps the older unpinned behavior. - The CA is staged in a private
mktempfile (random name,0600,PrivateTmpon both setup units) rather than a fixed/tmppath, so it is not a cross-user pre-seed or symlink-TOCTOU target on multi-user hosts.
Source¶
behaviors/docker-registry-cache-proxy/default.nix
# docker-registry-cache-proxy
#
# Point BOTH rootful and rootless Docker at an HTTP pull-through registry
# cache whose TLS is terminated with a self-signed CA. The whole trick is
# that the CA must be trusted BEFORE docker.service starts, and it must be
# installed under certs.d/ for the proxy host AND for both upstream Docker
# Hub hostnames (registry-1.docker.io and registry.docker.io), because the
# daemon consults certs.d/ keyed by the registry it thinks it is talking to.
#
# A oneshot fetches the CA over the network with a retry loop (the network
# may not be up yet when the unit runs) and installs it before Docker.
#
# Usage:
# imports = [ ./docker-registry-cache-proxy ];
# behaviors.docker-cache = {
# enable = true;
# cacheUrl = "https://docker-cache.example.com"; # your pull-through cache
# # proxyPort = 3128; # HTTP proxy port (default 3128)
# # caCertPath = "/ca.crt"; # path on cacheUrl serving the CA
# };
{
config,
lib,
pkgs,
...
}:
let
cfg = config.behaviors.docker-cache;
# Parse a "scheme://host[:port][/path]" URL into parts without importing
# anything fleet-specific. Falls back to sane defaults on no match.
parseUrl =
url:
let
m = builtins.match "^(https?)://([^:/]+)(:([0-9]+))?(/.*)?$" url;
scheme = if m != null then builtins.elemAt m 0 else "https";
host = if m != null then builtins.elemAt m 1 else url;
port =
if m != null && builtins.elemAt m 3 != null then
lib.toInt (builtins.elemAt m 3)
else if scheme == "https" then
443
else
80;
in
{
inherit scheme host port;
};
in
{
options.behaviors.docker-cache = {
enable = lib.mkEnableOption "Use a docker registry cache proxy";
cacheUrl = lib.mkOption {
type = lib.types.str;
example = "https://docker-cache.example.com";
description = ''
Base URL of the pull-through registry cache. Its self-signed CA is
fetched from this host (see caCertPath) and trusted before Docker starts.
'';
};
proxyPort = lib.mkOption {
type = lib.types.port;
default = 3128;
description = ''
Port on the cache host that speaks the Docker registry / HTTP proxy
protocol. The Docker daemon is pointed at http://<cache-host>:<proxyPort>.
'';
};
caCertPath = lib.mkOption {
type = lib.types.str;
default = "/ca.crt";
description = "Path, relative to cacheUrl, that serves the cache's CA certificate.";
};
caCertSha256 = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
description = ''
Optional lowercase-hex SHA-256 of the CA certificate PEM. When set, the
downloaded CA is verified against this hash and installation fails closed
on any mismatch. This pins the boot-time trust bootstrap so an on-path
attacker who intercepts the (possibly plaintext) CA fetch cannot inject a
CA that Docker would trust for the real Docker Hub hostnames. Leave null
to keep the previous unpinned behavior.
'';
};
maxAttempts = lib.mkOption {
type = lib.types.ints.positive;
default = 30;
description = "How many times the setup oneshot retries the CA download before failing.";
};
enableRootless = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Also install the CA and proxy config for rootless Docker running inside
per-user systemd sessions (writes ~/.config/docker and ~/.docker/config.json).
'';
};
};
config = lib.mkIf cfg.enable (
let
urlParts = parseUrl cfg.cacheUrl;
cacheProtocol = urlParts.scheme;
cacheDomain = urlParts.host;
cachePort = urlParts.port;
proxyPort = cfg.proxyPort;
proxyUrl = "http://${cacheDomain}:${toString proxyPort}";
caCertUrl = "${cacheProtocol}://${cacheDomain}${
if cachePort == 443 || cachePort == 80 then "" else ":${toString cachePort}"
}${cfg.caCertPath}";
# certs.d directory names. The daemon looks up the CA by the registry
# endpoint it addresses, so ALL THREE must be present:
# - the proxy host:port itself
# - registry-1.docker.io (the Docker Hub data endpoint)
# - registry.docker.io (the Docker Hub auth/index endpoint)
certsdHosts = [
"${cacheDomain}:${toString proxyPort}"
"registry-1.docker.io"
"registry.docker.io"
];
dockerDaemonConfig = {
proxies = {
"http-proxy" = proxyUrl;
"https-proxy" = proxyUrl;
"no-proxy" = "localhost,127.0.0.1";
};
};
# Retry loop: on early boot the network may not be reachable even after
# network-online.target, so keep trying instead of failing the boot.
# The CA is staged in a per-invocation private temp file (mktemp: random
# name, 0600) rather than a fixed world-writable /tmp path, so it is not a
# cross-user pre-seed / symlink TOCTOU target on multi-user hosts. Callers
# must set `CA_TMP="$(mktemp)"` (and rm it) around these snippets.
downloadCaCert = ''
max_attempts=${toString cfg.maxAttempts}
attempt=0
while [ $attempt -lt $max_attempts ]; do
if ${pkgs.curl}/bin/curl -f -s -o "$CA_TMP" "${caCertUrl}"; then
echo "Successfully downloaded CA certificate"
break
else
attempt=$((attempt + 1))
if [ $attempt -eq $max_attempts ]; then
echo "Failed to download CA certificate from ${caCertUrl} after $max_attempts attempts"
exit 1
fi
echo "Attempt $attempt/$max_attempts failed, retrying in 2 seconds..."
sleep 2
fi
done${lib.optionalString (cfg.caCertSha256 != null) ''
if ! echo "${cfg.caCertSha256} $CA_TMP" | ${pkgs.coreutils}/bin/sha256sum -c - >/dev/null 2>&1; then
echo "CA certificate SHA-256 mismatch (expected ${cfg.caCertSha256}) — refusing to install" >&2
exit 1
fi
echo "CA certificate SHA-256 verified"''}'';
installCa = target: ''
${pkgs.coreutils}/bin/install -m 644 "$CA_TMP" \
${target}'';
installCaBundle = target: ''
${pkgs.coreutils}/bin/cat ${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt \
"$CA_TMP" > ${target}
${pkgs.coreutils}/bin/chmod 644 ${target}'';
rootlessConfig = lib.mkIf cfg.enableRootless {
systemd.user.services.docker.environment = {
HTTP_PROXY = proxyUrl;
HTTPS_PROXY = proxyUrl;
NO_PROXY = "localhost,127.0.0.1";
SSL_CERT_FILE = "%h/.config/docker/ca-bundle.crt";
};
systemd.user.services.docker-rootless-ca-setup = {
description = "Download and setup Docker CA for rootless registry cache proxy";
wantedBy = [ "default.target" ];
before = [ "docker.service" ];
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
PrivateTmp = true;
ExecStart = pkgs.writeShellScript "docker-rootless-ca-setup" ''
set -e
CA_TMP="$(${pkgs.coreutils}/bin/mktemp)"
trap 'rm -f "$CA_TMP"' EXIT
echo "Downloading CA certificate for rootless Docker"
${lib.concatMapStringsSep "\n" (h: ''
mkdir -p "$HOME/.config/docker/certs.d/${h}"'') certsdHosts}
${downloadCaCert}
${lib.concatMapStringsSep "\n" (
h: installCa ''"$HOME/.config/docker/certs.d/${h}/ca.crt"''
) certsdHosts}
${installCaBundle ''"$HOME/.config/docker/ca-bundle.crt"''}
mkdir -p "$HOME/.local/share/ca-certificates"
${installCa ''"$HOME/.local/share/ca-certificates/docker-cache-ca.crt"''}
mkdir -p "$HOME/.docker"
if [ -f "$HOME/.docker/config.json" ]; then
cp "$HOME/.docker/config.json" "$HOME/.docker/config.json.bak"
${pkgs.jq}/bin/jq '.proxies = {
"default": {
"httpProxy": "${proxyUrl}",
"httpsProxy": "${proxyUrl}",
"noProxy": "localhost,127.0.0.1"
}
}' "$HOME/.docker/config.json.bak" > "$HOME/.docker/config.json"
else
cat > "$HOME/.docker/config.json" <<EOF
{
"proxies": {
"default": {
"httpProxy": "${proxyUrl}",
"httpsProxy": "${proxyUrl}",
"noProxy": "localhost,127.0.0.1"
}
}
}
EOF
fi
chmod 600 "$HOME/.docker/config.json"
echo "CA certificate installed for rootless Docker"
'';
};
};
};
in
lib.mkMerge [
{
virtualisation.docker.daemon.settings = dockerDaemonConfig;
systemd.services.docker.environment = {
HTTP_PROXY = proxyUrl;
HTTPS_PROXY = proxyUrl;
NO_PROXY = "localhost,127.0.0.1";
SSL_CERT_FILE = "/etc/docker/ca-bundle.crt";
};
systemd.tmpfiles.rules = [
"d /etc/docker/certs.d 0755 root root -"
] ++ map (h: "d /etc/docker/certs.d/${h} 0755 root root -") certsdHosts;
systemd.services.docker-ca-setup = {
description = "Download and setup Docker CA for registry cache proxy";
wantedBy = [ "docker.service" ];
before = [ "docker.service" ];
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
Restart = "on-failure";
RestartSec = "10s";
PrivateTmp = true;
ExecStart = pkgs.writeShellScript "docker-ca-setup" ''
set -e
CA_TMP="$(${pkgs.coreutils}/bin/mktemp)"
trap 'rm -f "$CA_TMP"' EXIT
echo "Downloading CA certificate from docker cache proxy"
${downloadCaCert}
${lib.concatMapStringsSep "\n" (
h: installCa "/etc/docker/certs.d/${h}/ca.crt"
) certsdHosts}
${installCaBundle "/etc/docker/ca-bundle.crt"}
echo "CA certificate installed for Docker daemon"
'';
};
};
}
rootlessConfig
]
);
}