Skip to content

Docker Proxy

NORA can act as a pull-through cache for upstream Docker registries. When an image is not found locally, NORA fetches it from the configured upstream, caches it, and serves it to the client. Subsequent pulls are served from cache.

docker pull nora:4000/library/nginx:latest
┌──────┐ cache hit ┌─────────┐
│ NORA │ ───────────────► │ storage │
└──┬───┘ └─────────┘
│ cache miss
┌────────────┐ fetch + async cache
│ upstream │ ──────────────────────► storage
│ (Docker Hub)│
└────────────┘
  1. Client requests an image from NORA
  2. NORA checks local storage — if found, returns immediately (cache hit)
  3. If not found, tries each configured upstream sequentially
  4. First successful response is returned to the client
  5. Image is cached asynchronously in the background (fire-and-forget)
  6. Next pull of the same image is served from cache

Two environment variables control the proxy behavior:

VariableDefaultDescription
NORA_DOCKER_PROXIEShttps://registry-1.docker.ioComma-separated list of upstream registries

Note: NORA_DOCKER_UPSTREAMS is deprecated and works as a backward-compatible alias. Use NORA_DOCKER_PROXIES.

| NORA_DOCKER_PROXY_TIMEOUT | 300 | HTTP timeout for upstream requests (seconds) |

Default — no configuration needed. NORA proxies to Docker Hub out of the box:

Terminal window
docker pull nora.internal:4000/library/nginx:latest

NORA tries upstreams in order. If Docker Hub is down or doesn’t have the image, it falls back to the next:

Terminal window
NORA_DOCKER_PROXIES="https://registry-1.docker.io,https://ghcr.io,https://quay.io"
[docker]
proxy_timeout = 60
[[docker.upstreams]]
url = "https://registry-1.docker.io"
[[docker.upstreams]]
url = "https://ghcr.io"
services:
nora:
image: ghcr.io/getnora-io/nora:latest
ports:
- 4000:4000
volumes:
- nora-data:/data
environment:
- NORA_HOST=0.0.0.0
- NORA_DOCKER_PROXIES=https://registry-1.docker.io,https://ghcr.io
- NORA_DOCKER_PROXY_TIMEOUT=30
restart: unless-stopped
volumes:
nora-data:
Terminal window
# Instead of: docker pull nginx:latest
docker pull nora.internal:4000/library/nginx:latest
# Instead of: docker pull redis:7-alpine
docker pull nora.internal:4000/library/redis:7-alpine
# Non-library images (user repos):
docker pull nora.internal:4000/grafana/grafana:latest

If GHCR is in your upstreams list and the image is not on Docker Hub:

Terminal window
docker pull nora.internal:4000/getnora-io/nora:latest

Configure Docker daemon to use NORA as a registry mirror. Edit /etc/docker/daemon.json:

{
"registry-mirrors": ["http://nora.internal:4000"]
}

Restart Docker:

Terminal window
sudo systemctl restart docker

Now all docker pull commands automatically go through NORA:

Terminal window
# This now pulls via NORA cache:
docker pull nginx:latest

For public images, NORA handles authentication automatically:

  1. Sends request to upstream
  2. On 401 Unauthorized, extracts Www-Authenticate header
  3. Fetches a bearer token from the upstream’s auth service
  4. Retries with the token
  5. Tokens are cached for 5 minutes per registry/repository

Docker Hub, GHCR, and Quay all support anonymous pulls for public repositories.

For private upstream registries that require credentials, use the url|user:pass format:

Terminal window
NORA_DOCKER_PROXIES="https://registry.corp.com|admin:secret,https://registry-1.docker.io"

Or in config.toml:

[[docker.upstreams]]
url = "https://registry.corp.com"
auth = "admin:secret"
[[docker.upstreams]]
url = "https://registry-1.docker.io"

When credentials are configured, NORA sends Basic Auth on the initial request (preemptive auth) and also uses them for bearer token acquisition.

Any Docker Registry v2 API-compatible registry works:

RegistryURL
Docker Hubhttps://registry-1.docker.io
GitHub Container Registryhttps://ghcr.io
Quay.iohttps://quay.io
GitLab Container Registryhttps://registry.gitlab.com
Google Artifact Registryhttps://REGION-docker.pkg.dev
AWS ECRhttps://ACCOUNT.dkr.ecr.REGION.amazonaws.com

NORA tracks cache performance via Prometheus metrics at /metrics:

  • nora_cache_requests_total{result="hit"} — images served from local cache
  • nora_cache_requests_total{result="miss"} — images fetched from upstream
  • nora_http_requests_total{registry="docker"} — total Docker HTTP operations

Cache hit rate is also displayed on the Web UI dashboard.

Controls how long cached manifests and tag lists are considered fresh before NORA rechecks the upstream.

ValueBehavior
-1 (default)Cache forever — never refetch from upstream
0Always refetch — check upstream on every request
> 0Cache for N seconds, then refetch
Terminal window
export NORA_DOCKER_METADATA_TTL=300 # 5 minutes
[docker]
metadata_ttl = 300

When to use each mode:

  • -1 (forever): Air-gapped environments, or when you only want images that are explicitly pushed to NORA.
  • 0 (always refetch): Development environments where you need the latest upstream tags immediately.
  • 300-3600 (seconds): Production proxy mode — balance freshness with upstream rate limits.

When stale_while_error = true (default), NORA serves expired cached manifests if the upstream is unreachable, rather than returning an error to the client.

[docker]
stale_while_error = true # default

This provides resilience against upstream outages — your builds continue working from cache even if Docker Hub is down.


When proxying multiple upstream registries, NORA isolates cached images by namespace to avoid collisions. Each upstream gets a namespace derived from its hostname:

Upstream URLDerived Namespace
https://registry-1.docker.iodocker.io
https://ghcr.ioghcr.io
https://quay.ioquay.io

Images pushed directly to NORA (not from an upstream) are stored without a namespace prefix.

Override the derived namespace with an explicit value:

[[docker.upstreams]]
url = "https://registry-1.docker.io"
namespace = "dockerhub" # custom prefix instead of "docker.io"
data/storage/docker/
manifests/
myapp/v1.json # locally-pushed image
docker.io/library/nginx/latest.json # proxied from Docker Hub
ghcr.io/org/tool/v2.json # proxied from GHCR

This prevents tag collisions when different upstreams have images with the same name.


For large blob downloads (multi-GB ML model layers), NORA uses chunked streaming from upstreams. The read_timeout controls the per-chunk timeout, not the total transfer time:

Terminal window
export NORA_DOCKER_READ_TIMEOUT=60 # seconds per chunk (default)
[docker]
read_timeout = 60

If a single chunk takes longer than this timeout, the transfer is aborted. For slow or high-latency upstreams, increase this value:

[docker]
read_timeout = 120 # 2 minutes per chunk for slow upstreams

The circuit breaker prevents cascading failures when an upstream registry is down. Instead of waiting for every request to timeout, NORA “opens the circuit” after consecutive failures and returns 503 immediately.

[circuit_breaker]
enabled = true
failure_threshold = 5 # failures before opening
reset_timeout = 30 # seconds before trying upstream again
Terminal window
export NORA_CB_ENABLED=true
export NORA_CB_THRESHOLD=5
export NORA_CB_RESET_TIMEOUT=30
StateBehavior
ClosedNormal operation, requests go to upstream
OpenUpstream is down — returns 503 with Retry-After header
Half-OpenAfter reset_timeout, allows one probe request through

Different upstreams may have different reliability profiles. Override thresholds per registry:

[circuit_breaker]
enabled = true
failure_threshold = 5
reset_timeout = 30
[circuit_breaker.overrides."docker:https://registry-1.docker.io"]
failure_threshold = 10 # Docker Hub is flaky, be more patient
reset_timeout = 60
[circuit_breaker.overrides."docker:https://internal.registry.corp"]
failure_threshold = 2 # internal -- fail fast if it's down
reset_timeout = 10

The override key format is {registry_type}:{upstream_url}.


  • Pull only — proxy is read-only. Push goes directly to NORA’s local storage, not forwarded upstream.
  • Sequential fallback — upstreams are tried one by one, not in parallel. If the first upstream times out, latency adds up.
  • Async caching — cache writes happen in the background. If NORA restarts during caching, the image won’t be cached and will be fetched again on next pull.