Docker Registry: Because Harbor is Too Good for ARM64
I needed a container registry for the cluster. Something to push my own images to, especially for KubeVirt VM disk images. Harbor seemed like the obvious choice – it's the industry standard, has a nice UI, vulnerability scanning, the works.
Except Harbor doesn't officially support ARM64 yet. There are community builds and some PRs in flight for v2.14, but I'm running a Raspberry Pi cluster. I don't have time to debug multi-arch manifest issues for a registry that's basically 7+ components just to store some container images.
So: Docker Registry v2. The reference implementation. One container. Works on ARM64. Done.
What is Docker Registry v2?
Docker Registry v2 is the OCI Distribution spec reference implementation. It's what Harbor uses internally for the actual registry component. Everything else Harbor provides (web UI, RBAC, scanning, replication) is management layer on top.
For a single-user homelab, I don't need any of that. I just need:
- Push images (
docker push registry.goldentooth.net/myapp:v1) - Pull images (
docker pull registry.goldentooth.net/myapp:v1) - Store everything in SeaweedFS S3
- TLS via cert-manager
Registry v2 does all of this in ~50MB of RAM.
The Architecture
Client (docker/skopeo)
↓ HTTPS (TLS via cert-manager)
LoadBalancer Service (10.4.11.8)
↓
Registry Pod (single container)
├── Config: /etc/docker/registry/config.yml
├── Certs: /certs/tls.{crt,key} (from cert-manager)
└── S3 Backend → SeaweedFS Filer
↓
Volume Servers (USB SSDs)
The registry is stateless – all image data lives in SeaweedFS S3 (harbor-registry bucket), and the registry just coordinates uploads/downloads.
The Deployment
I created the Flux structure under gitops/infrastructure/docker-registry/:
Namespace and Certificate
---
apiVersion: v1
kind: Namespace
metadata:
name: docker-registry
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: registry-tls
namespace: docker-registry
spec:
secretName: registry-tls
duration: 24h
renewBefore: 8h
commonName: registry.goldentooth.net
dnsNames:
- registry.goldentooth.net
issuerRef:
name: step-ca
kind: StepClusterIssuer
group: certmanager.step.sm
cert-manager + Step-CA handles TLS automatically with 24-hour certificate rotation.
Registry Configuration
The registry uses a YAML config mounted from a ConfigMap:
storage:
redirect:
disable: true # Important! Explained later.
s3:
region: us-east-1
regionendpoint: http://goldentooth-storage-filer.seaweedfs.svc.cluster.local:8333
bucket: harbor-registry
secure: false
v4auth: true
delete:
enabled: true
cache:
blobdescriptor: inmemory
http:
addr: :5000
tls:
certificate: /certs/tls.crt
key: /certs/tls.key
S3 credentials come from environment variables (REGISTRY_STORAGE_S3_ACCESSKEY / REGISTRY_STORAGE_S3_SECRETKEY) loaded from a SOPS-encrypted Secret.
Deployment and Service
Single-pod Deployment with aggressive resource limits (0.25 CPU, 256MB RAM – this is a homelab):
apiVersion: apps/v1
kind: Deployment
metadata:
name: docker-registry
namespace: docker-registry
spec:
replicas: 1
template:
spec:
containers:
- name: registry
image: registry:2
env:
- name: REGISTRY_STORAGE_S3_ACCESSKEY
valueFrom:
secretKeyRef:
name: registry-s3-secret
key: accesskey
- name: REGISTRY_STORAGE_S3_SECRETKEY
valueFrom:
secretKeyRef:
name: registry-s3-secret
key: secretkey
volumeMounts:
- name: config
mountPath: /etc/docker/registry
- name: certs
mountPath: /certs
LoadBalancer Service with external-dns annotation:
apiVersion: v1
kind: Service
metadata:
name: docker-registry
annotations:
external-dns.alpha.kubernetes.io/hostname: registry.goldentooth.net
spec:
type: LoadBalancer
ports:
- port: 443
targetPort: 5000
MetalLB assigns an IP, external-dns creates the DNS record, cert-manager provides the cert. Everything automatic.
The Problems: A TLS Odyssey
I deployed everything. Flux reconciled. The registry pod started. The LoadBalancer got an IP. DNS resolved. The certificate was issued.
Time to test:
$ docker push registry.goldentooth.net/test/busybox:latest
denied:
Problem 1: Certificate Chain Issues
The error was actually a TLS verification failure, but Docker just said "denied" with no details. Helpful.
The issue: Docker wasn't trusting the Step-CA certificate. I added the root CA to the macOS system keychain:
$ kubectl get configmap -n step-ca step-ca-step-ca-step-certificates-certs \
-o jsonpath='{.data.root_ca\.crt}' > /tmp/goldentooth-ca.crt
$ sudo security add-trusted-cert -d -r trustRoot \
-k /Library/Keychains/System.keychain /tmp/goldentooth-ca.crt
Docker still failed. Turns out Docker Desktop on macOS doesn't use the system keychain – it has its own certificate store at ~/.docker/certs.d/<hostname>/ca.crt.
I copied the certificate there:
$ mkdir -p ~/.docker/certs.d/registry.goldentooth.net
$ cp /tmp/goldentooth-ca.crt ~/.docker/certs.d/registry.goldentooth.net/ca.crt
Restarted Docker Desktop. Still failed.
Turns out the registry was serving a certificate signed by an intermediate CA, not the root. I needed the full chain:
$ openssl s_client -connect registry.goldentooth.net:443 -showcerts 2>/dev/null </dev/null | \
sed -n '/BEGIN CERTIFICATE/,/END CERTIFICATE/p' > \
~/.docker/certs.d/registry.goldentooth.net/ca.crt
Still failed.
Problem 2: The Docker Desktop Bug
After a few more minutes of TLS debugging, I found the real issue: Docker Desktop 4.32+ (including my version 28.0.1) has a broken insecure-registries implementation on macOS.
There's a confirmed bug where the setting is completely ignored. This started in mid-2024 and affects all recent versions. Docker uploads blobs successfully, then refuses to push the manifest with "denied" – but the registry logs show Docker never even tried to push the manifest.
The registry was working fine. Docker blobs were uploading to S3. Docker was just... giving up before pushing the final manifest for no clear reason.
Solution: Use Skopeo
Skopeo is a Docker alternative that doesn't require a daemon and doesn't have Docker Desktop's bugs. Installed it:
$ brew install skopeo
Pushed the image:
$ skopeo copy --dest-tls-verify=false \
docker-daemon:alpine:latest \
docker://registry.goldentooth.net/test/alpine:v1
Getting image source signatures
Copying blob sha256:0e64f2360a44...
Copying config sha256:171e65262c80...
Writing manifest to image destination
It worked. First try. Blobs, config, and manifest all pushed successfully.
Verified with Docker pull:
$ docker pull registry.goldentooth.net/test/alpine:v1
v1: Pulling from test/alpine
Digest: sha256:6ecfe31476d1...
Status: Downloaded newer image
Perfect. The registry works. Docker Desktop is just broken.
Problem 3: The S3 Redirect Issue
I tested pulling with skopeo:
$ skopeo copy docker://registry.goldentooth.net/test/alpine:v1 docker-daemon:test:latest
time="2025-11-29T14:04:05-05:00" level=fatal msg="reading blob: Get \"http://goldentooth-storage-filer.seaweedfs.svc.cluster.local:8333/...\": no such host"
The registry was sending skopeo a 307 redirect to the internal SeaweedFS S3 endpoint (.svc.cluster.local), which isn't resolvable from outside the cluster.
What's Happening
By default, Docker Registry sends HTTP 307 redirects for blob downloads. Instead of proxying the image layer data through itself, it tells clients: "go fetch this blob directly from S3 at this URL."
This is efficient for large registries (saves bandwidth on the registry pod), but only works if clients can reach the S3 endpoint. My S3 endpoint was cluster-internal.
The Fix: Disable Redirects
Added one line to the registry config:
storage:
redirect:
disable: true # Registry proxies all blob data
s3:
# ... rest of config
This makes the registry proxy all blob downloads instead of redirecting clients to S3. Uses more bandwidth on the registry pod, but for a homelab with minimal usage, it's fine.
Applied the change:
$ kubectl apply -f config.yaml
$ kubectl rollout restart deployment -n docker-registry docker-registry
Tested again:
$ skopeo copy docker://registry.goldentooth.net/test/alpine:v1 docker-daemon:test:latest
Getting image source signatures
Copying blob sha256:5096682701dd...
Copying config sha256:171e65262c80...
Writing manifest to image destination
Success. Skopeo can now both push and pull.