I was playing with my new OKD cluster and getting some workloads running. It was all simple enough, but I really don’t like spending time doing things that are not prod-like.
I know that most shops are probably not trusting upstream registries , additionally OpenShift does not allow containers to run as root by default, and many upstream images assume they are root. I wanted a way to rebuild upstream container images so they satisfy OKD/OpenShift security requirements, store them locally in Harbor, and deploy them reliably without pulling from upstream registries at runtime.
So I went on the hunt for a enterprise container registry and landed on Harbor.
This document details my installation of Harbor. Because I have not yet implemented a Certificate Authority in my environment and I’m being deliberate about where I spend my time this installation is configured to use HTTP rather than HTTPS.
I initially attempted to use self-signed certificates, but consistently ran into certificate-related errors across multiple components. While switching Harbor to HTTP introduced a different set of issues, those problems were at least predictable and debuggable, allowing me to make forward progress.
For now, the goal of this deployment is functionality and understanding, not production-grade TLS. HTTPS will be introduced later once a proper CA strategy is in place.
Install harbor http
Pull images from dockerhub
Push them into the "golden" Project
Configure OKD to pull from harbor
Test a non-root image (grafana)
Test an image that has to be rebuilt to run within OKD's constraints.
vv-harbor-01.vv-int.io172.26.2.10/opt/harbordnf update -y
dnf install -y curl wget tar vim firewalld dnf-plugins-core
systemctl enable --now firewalld
Open required ports (HTTP only):
firewall-cmd --add-port=80/tcp --permanent
firewall-cmd --reload
Add Docker repo:
dnf config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
Install Docker:
dnf install -y docker-ce docker-ce-cli
Enable and start Docker:
systemctl enable --now docker
Verify:
docker version
curl -L https://github.com/docker/compose/releases/download/v2.27.0/docker-compose-linux-x86_64 \
-o /usr/local/bin/docker-compose
chmod +x /usr/local/bin/docker-compose
docker compose version
cd /opt
wget https://github.com/goharbor/harbor/releases/download/v2.10.0/harbor-offline-installer-v2.10.0.tgz
tar xvf harbor-offline-installer-v2.10.0.tgz
cd harbor
/etc/hosts (REQUIRED)This is the authoritative fix we actually used.
Edit /etc/hosts:
vi /etc/hosts
Add this exact line at the top (above other entries), whatever hostname you choose:s
172.26.2.10 vv-harbor-01.vv-int.io
⚠️ Do NOT point this hostname to 127.0.0.1 or ::1.
Verify:
getent hosts vv-harbor-01.vv-int.io
getent hosts vv-harbor-01.vv-int.io
Expected output (ONE line only):
172.26.2.10 vv-harbor-01.vv-int.io
172.26.2.10 vv-harbor-01.vv-int.io
If you see ::1 or fe80::, stop and fix this before continuing.
Even with /etc/hosts pinned, mDNS can reintroduce IPv6 resolution in other contexts.
Edit /etc/nsswitch.conf:
vi /etc/nsswitch.conf
Change:
hosts: files mdns4_minimal [NOTFOUND=return] dns
To:
hosts: files dns
This makes DNS and /etc/hosts authoritative.
Re-verify:
getent hosts vv-harbor-01.vv-int.io
It must still return IPv4 only.
cp harbor.yml.tmpl harbor.yml
Edit harbor.yml:
hostname: vv-harbor-01.vv-int.io http: port: 80
hostname: vv-harbor-01.vv-int.io
http:
port: 80
# HTTPS MUST NOT EXIST AT ALL
# (commenting incorrectly can still break nginx)
# https:
# port: 443
harbor_admin_password: Harbor12345
database:
password: HarborDB12345
⚠️ Do not leave an empty https: block.
./prepare
Expected output includes:
Generated configuration file: ./docker-compose.yml
Generated configuration file: ./common/config/nginx/nginx.conf
If prepare fails → do not proceed.
docker compose up -d
Verify all services:
docker compose ps
All must be Up (healthy).
ss -lntp | grep :80
Expected:
LISTEN 0.0.0.0:80 docker-proxy
Docker ALWAYS assumes HTTPS on port 443 unless told otherwise.
Therefore, HTTP registries REQUIRE insecure-registries.
vi /etc/docker/daemon.json
{
"insecure-registries": [
"vv-harbor-01.vv-int.io"
]
}
Restart Docker:
systemctl restart docker
Verify:
docker info | grep -A5 -i insecure
You MUST see the registry listed.
⚠️ Restarting Docker stops Harbor.
Bring it back:
cd /opt/harbor
docker compose up -d
Use the hostname, not IP:
curl -H "Host: vv-harbor-01.vv-int.io" http://127.0.0.1
Browser:
http://vv-harbor-01.vv-int.io
Harbor does not serve a default vhost.
UI → Administration → Registries → New Endpoint
Provider: Docker Hub
Name: dockerhub
Auth: none (public images)
Test Connection: OK
You can Create two types of Projects in Harbor
Proxy Cache* = Harbor acting as a mirror in front of someone else’s registry*
Project = Harbor being the source of truth for images you own
We will be creating a standard Project, I like truth.
Projects → New Project
Name: golden
Type: standard
Provider: dockerhub
Access: Private
First pull will FAIL with:
no basic auth credentials
This is correct behavior.
Login:
docker login vv-harbor-01.vv-int.io
Use Harbor admin credentials.
docker pull vv-harbor-01.vv-int.io/dockerhub-apps/library/nginx:1.25
Expected:
Layers download
Image cached locally
Image visible in Harbor UI
systemctl restart docker
cd /opt/harbor
docker compose up -d
Harbor does not auto-start.
| Symptom | Root Cause | Fix |
|---|---|---|
::1 / fe80:: |
mDNS + IPv6 | hosts: files dns |
| TLS unknown authority | Docker token HTTPS | Use HTTP first |
| Harbor UI blank | Host header mismatch | Use hostname |
| Pull access denied | Private project | docker login |
| Harbor down after reboot | Docker restart | docker compose up -d |
Proxy Cache = Harbor acting as a mirror in front of someone else’s registry
Project = Harbor being the source of truth for images you own---
This is basically what we will be doing below
Upstream Image
↓
Harbor Host (Docker/Podman)
↓ (optional modification)
Golden Harbor Project
↓
Robot Account (pull)
↓
OKD Worker Nodes
↓
Running Pod + Route
I also want to set the project to private because I already punted on the TLS and CA. I really wanted to force "auth correctess"
Pull the Grafana image from Docker Hub into Harbor and place it in the golden image project
Configure OKD to pull images from Harbor’s private golden repository and verify that Grafana runs successfully
Repeat the process for phpMyAdmin, demonstrating that it fails to run due to default security constraints (e.g., root user)
Modify and rebuild the phpMyAdmin image to comply with OKD requirements
Publish the updated phpMyAdmin image to the golden repository
Pull the corrected image into OKD and verify that it runs successfully
Log into Harbor
Create project:
Name: golden
Visibility: Private
Forces auth correctness
Prevents accidental anonymous pulls
Matches real org security posture, I think anyway 😀
Inside golden project → Robot Accounts
Create robot:
Name: robot$golden-pull
Name: robot$golden-pull
Permissions:
Pull repository
Push repository
Save the generated token — this is the password.
Note: I gave both pull and push to this one robot. I assume in prod that these accounts would be sperate pull and push. OKD would use the pull for deploying images, this reduces the attack surfact if your note was comprimised
On the Harbor VM:
docker login vv-harbor-01.vv-int.io
Use:
Username: admin
Password: Harbor admin password
You are building and pushing images
Robot accounts are for consumers (OKD), not builders
Pull Upstream phpMyAdmin Image
From the Harbor host:
docker pull grafana/grafana:12.4.0-21230963995
This confirms:
Internet access
Docker is functional
You have a known-good base image
Next we tag the image:
docker tag grafana/grafana:12.4.0-21230963995 \
vv-harbor-01.vv-int.io/golden/grafana:12.4.0
Then we push it to the golden repo:
docker push vv-harbor-01.vv-int.io/golden/grafana:12.4.0
From the Harbor host:
docker pull phpmyadmin/phpmyadmin:5.2.1
This confirms:
Internet access
Docker is functional
You have a known-good base image
Out of the box, the upstream phpmyadmin/phpmyadmin image assumes:
It can:
write to /etc/phpmyadmin
bind to privileged port 80
run as a predictable user/group
Kubernetes/OpenShift reality:
Random UID
Read-only root filesystem in many cases
No privileged ports unless explicitly allowed
SCC does not allow root assumptions
That’s why you saw errors like:
Permission denied: /etc/phpmyadmin/config.secret.inc.php
make_sock: could not bind to address 0.0.0.0:80
These are expected failures, not misconfiguration.
This is the entire point of a golden registry:
Fix the image once
Never debug it again
Every cluster deploy behaves the same
On the Harbor host:
mkdir -p ~/phpmyadmin-golden
cd ~/phpmyadmin-golden
Why:
Keeps Dockerfiles auditable
Makes rebuilds repeatable
Matches enterprise image pipelines
Create Dockerfile:
FROM phpmyadmin/phpmyadmin:5.2.1
# Switch Apache to non-privileged port
RUN sed -i 's/Listen 80/Listen 8080/' /etc/apache2/ports.conf && \
sed -i 's/:80/:8080/g' /etc/apache2/sites-enabled/000-default.conf
# Ensure writable dirs for random UID
RUN mkdir -p /etc/phpmyadmin && \
chgrp -R 0 /etc/phpmyadmin && \
chmod -R g=u /etc/phpmyadmin
# Expose non-privileged port
EXPOSE 8080
# Do NOT force USER — let OpenShift inject one
8080 instead of 80
Required for non-root SCC
chgrp 0 + g=u
OpenShift random UID is always in group 0
No USER directive
OpenShift sets it dynamically
This is canonical OKD hardening.
docker build -t vv-harbor-01.vv-int.io/golden/phpmyadmin:5.2.1-okd .
Verify:
docker images | grep phpmyadmin
docker push vv-harbor-01.vv-int.io/golden/phpmyadmin:5.2.1-okd
Expected output:
Layers push successfully
Digest printed
Image visible in Harbor UI under golden/phpmyadmin
At this point:
Harbor owns the image
Upstream is no longer needed
Version is pinned
On an OKD node or admin workstation:
oc create secret docker-registry harbor-pull \
--docker-server=vv-harbor-01.vv-int.io \
--docker-username=robot$golden-pull \
--docker-password='<ROBOT_TOKEN>' \
[email protected] \
-n harbor-test
Attach it to the default service account:
oc secrets link default harbor-pull --for=pull -n harbor-test
Verify:
oc describe sa default -n harbor-test
You must see:
Image pull secrets: harbor-pull
oc create deployment phpmyadmin \
--image=vv-harbor-01.vv-int.io/golden/phpmyadmin:5.2.1-okd \
-n harbor-test
oc expose deployment/phpmyadmin \
--port=8080 \
-n harbor-test
oc expose svc/phpmyadmin -n harbor-test
Check:
oc get route phpmyadmin -n harbor-test
Open the URL:
http://phpmyadmin-harbor-test.apps.okd.vv-int.io
✅ Web UI loads
✅ No permission errors
✅ No crashes
✅ Fully OKD-compatible
Confirm image source
oc get pod -n harbor-test -l deployment=phpmyadmin \
-o jsonpath='{.items[0].spec.containers[0].image}'
Should point to:
vv-harbor-01.vv-int.io/golden/phpmyadmin@sha256:...
Check logs
oc logs deployment/phpmyadmin -n harbor-test
Healthy logs look like:
Apache configured -- resuming normal operations
Node-level pull test (ultimate truth)
oc debug node/<worker-node>
chroot /host
podman pull vv-harbor-01.vv-int.io/golden/phpmyadmin:5.2.1-okd
If this works, everything works.
This approach gives you:
✔ Version pinning
✔ No upstream dependency
✔ OKD-safe images
✔ Repeatable builds
✔ Clean security boundaries
✔ No proxy-cache weirdness
✔ No “why did it work yesterday” surprises
This is exactly how mature orgs run Harbor + OpenShift.
Harbor proxy cache → convenience, not control
Golden images → control, auditability, reliability
If OKD breaks it once, fix the image forever
If you want next steps, we can:
Turn this into a Makefile-based image pipeline
Add Trivy scanning + admission control
Build a Grafana / Adminer / Redis / Postgres golden suite
Convert this into a runbook.md