Skip to main content

Harbor Developer Guide

Use this guide to push and pull container images through Anton's Harbor registry. Keep credentials outside command arguments, logs, and retained output.

Endpoint and client matrix

UseEndpointClient locationTransport
LAN push or pull192.168.1.106Laptop or cluster nodeHTTP
Local tunnel pushharbor.localtest.me:18083Host-native clientHTTP
Cluster image pull192.168.1.106/library/<image>Kubernetes nodeHTTP
Web UIhttps://registry.<tailnet-name>.ts.netBrowserHTTPS

Cluster nodes run linux/amd64. Build and publish that platform explicitly.

The Docker engine can use a different network namespace from the command-line process. Docker Desktop and similar runtimes can place the engine in a virtual machine. That engine's 127.0.0.1 might not reach a Mac host port-forward.

Credential boundary

Use a scoped Harbor robot account for image pushes. Keep its token in the approved secret manager. Use protected standard input for authentication.

Do not retrieve the Harbor admin password from a Kubernetes Secret for routine pushes. Do not place any password or token in a command argument.

Obtain explicit approval before each image push. A local port-forward needs separate approval.

Create one isolated authentication directory before either push flow:

harbor_auth_dir="$(mktemp -d /tmp/anton-harbor-auth.XXXXXX)"
chmod 700 "$harbor_auth_dir"

Use this directory for every authenticated client command. Run the cleanup gate after a success or failure.

Repository paths

OCI image references use this shape:

<registry>/library/<image>:<tag>

Harbor management API references use this separate shape:

/api/v2.0/projects/library/repositories/<url-encoded-repository>

The library project permits anonymous pulls. Pushes still require authentication.

LAN push

Harbor's LAN endpoint uses HTTP. Configure the Docker engine to treat 192.168.1.106 as an insecure registry before this flow.

DOCKER_CONFIG="$harbor_auth_dir" mise exec -- docker login \
192.168.1.106 --username <robot-account>
DOCKER_CONFIG="$harbor_auth_dir" mise exec -- docker buildx build \
--platform linux/amd64 \
--tag 192.168.1.106/library/myapp:v1 \
--push .

The login command prompts for the token. Keep terminal logging disabled.

Local port-forward push

Use this flow when LAN access is unavailable. Obtain approval before starting the port-forward.

Start the tunnel in a dedicated terminal. Keep it in the foreground:

mise exec -- kubectl -n registries port-forward svc/harbor-core 18083:80

From another terminal, inspect the unauthenticated challenge:

mise exec -- curl --resolve harbor.localtest.me:18083:127.0.0.1 \
-sS -D - -o /dev/null http://harbor.localtest.me:18083/v2/ \
| rg -i '^www-authenticate:'

The upload endpoint and token realm must be reachable from the same client network namespace. The expected realm uses harbor.localtest.me:18083. Stop when the challenge points to another host or port.

Build and verify the archive

Use Docker only for the local build and archive creation:

mise exec -- docker buildx build \
--platform linux/amd64 \
--load \
--tag myapp:v1 .
mise exec -- docker image inspect myapp:v1 \
--format '{{.Os}}/{{.Architecture}}'
mise exec -- docker save --output /tmp/myapp-v1-linux-amd64.tar myapp:v1
mise exec -- wc -c /tmp/myapp-v1-linux-amd64.tar
mise exec -- shasum -a 256 /tmp/myapp-v1-linux-amd64.tar
mise exec -- tar -tf /tmp/myapp-v1-linux-amd64.tar >/dev/null

Require linux/amd64 and a successful archive listing. Stop after an unexpected EOF, changed byte count, or failed archive listing.

The prior large-image incident did not retain enough evidence for a narrower truncation repair. Recreate the archive before another push.

If an archive crosses a process or host boundary, verify its byte count and checksum at both ends before import. Do not trust the transfer exit alone.

Authenticate and push from the host

Pass the robot token from the approved secret manager through standard input:

mise exec -- op read 'op://anton/<harbor-robot-item>/<token-field>' \
| NO_PROXY=harbor.localtest.me,localhost,127.0.0.1,::1 \
no_proxy=harbor.localtest.me,localhost,127.0.0.1,::1 \
DOCKER_CONFIG="$harbor_auth_dir" mise exec -- crane auth login \
--insecure harbor.localtest.me:18083 \
--username <robot-account> \
--password-stdin

Push one archive to one repository tag:

NO_PROXY=harbor.localtest.me,localhost,127.0.0.1,::1 \
no_proxy=harbor.localtest.me,localhost,127.0.0.1,::1 \
DOCKER_CONFIG="$harbor_auth_dir" mise exec -- crane push \
--insecure \
/tmp/myapp-v1-linux-amd64.tar \
harbor.localtest.me:18083/library/myapp:v1

Do not substitute docker push localhost:18081 on macOS without proving the Docker engine shares the host loopback path.

Verify the remote image

NO_PROXY=harbor.localtest.me,localhost,127.0.0.1,::1 \
no_proxy=harbor.localtest.me,localhost,127.0.0.1,::1 \
DOCKER_CONFIG="$harbor_auth_dir" mise exec -- crane digest \
--insecure harbor.localtest.me:18083/library/myapp:v1
NO_PROXY=harbor.localtest.me,localhost,127.0.0.1,::1 \
no_proxy=harbor.localtest.me,localhost,127.0.0.1,::1 \
DOCKER_CONFIG="$harbor_auth_dir" mise exec -- crane config \
--insecure harbor.localtest.me:18083/library/myapp:v1 \
| mise exec -- jq -r '"\(.os)/\(.architecture)"'

Record the digest and require linux/amd64 before editing any manifest.

Clean up

Stop the foreground port-forward with Ctrl-C. Remove the run-owned authentication directory and archive:

case "$harbor_auth_dir" in
/tmp/anton-harbor-auth.*|/private/tmp/anton-harbor-auth.*)
;;
*)
exit 1
;;
esac
rm -f -- "$harbor_auth_dir/config.json"
rmdir -- "$harbor_auth_dir"
rm -f -- /tmp/myapp-v1-linux-amd64.tar

If rmdir fails, inspect the directory. Obtain approval before recursive deletion.

Verify that no temporary listener remains. Keep only the remote digest and nonsecret archive checksum in retained evidence.

Kubernetes pulls

The public library project does not require an image pull Secret:

apiVersion: v1
kind: Pod
metadata:
name: myapp
namespace: default
spec:
containers:
- name: myapp
image: 192.168.1.106/library/myapp:v1

Private projects require a scoped robot account and an ExternalSecret-managed kubernetes.io/dockerconfigjson Secret.

Troubleshooting

Direct tailnet push fails

Harbor currently returns an HTTP token realm for the tailnet hostname. The Tailscale ingress serves HTTPS. Use the approved host-native tunnel flow.

The local tunnel works with curl, but Docker fails

Confirm where the Docker engine runs. Host curl success does not prove that a virtual-machine engine can reach host loopback.

Push fails before upload

Check the registry scheme, token realm, client namespace, and repository path. An HTTP tunnel must use an insecure-capable client.

Crane cannot authenticate through the local tunnel

Use svc/harbor-core, not the aggregate Harbor service. Use the harbor.localtest.me:18083 alias so the registry and token realm match. Set both NO_PROXY and no_proxy for that alias before each Crane command.

Push stops during a large layer

Retain the client exit status and archive byte count. Recheck the archive checksum and listing. Recreate the archive when either value changes.

A Pod reports an architecture error

Inspect the remote image configuration. Publish linux/amd64, then update the committed digest through review.

Further reading