Background: When Docker Hub Became a Bottleneck
When our team expanded from 5 to over 30 microservices, pushing images to public Docker Hub quickly became problematic. First, subscription costs rose steeply. Second, Docker Hub imposes a rate limit of 200 pulls per 6 hours for anonymous or free accounts. This restriction once broke our CI/CD pipeline right in the middle of a peak release window. More importantly, internal security policies strictly prohibit hosting container images containing proprietary enterprise code on third-party cloud platforms.
At the time, our build infrastructure was migrating from legacy CentOS 7 servers to CentOS Stream 9. The team needed an on-premises private registry deployed right inside our office LAN. It had to provide role-based access control (RBAC), storage quota management, and automated vulnerability scanning before deploying to the production Kubernetes cluster.
After evaluating Nexus OSS and Harbor, we chose Harbor—a CNCF Graduated open-source project. Harbor neatly solves three major challenges:
- Speed and control: All data resides on local servers. You can pull an 800MB image over a 1Gbps LAN link in seconds without consuming external internet bandwidth.
- Proactive security: The integrated Trivy engine automatically scans image layers. You can enforce policies that immediately block pull requests if an image contains Critical CVEs.
- Centralized administration: Built-in support for LDAP/Active Directory integration, granular project-level RBAC, and container image signing with Cosign.
Installing Prerequisites on CentOS Stream 9
Harbor runs as a multi-container application orchestrated by Docker Compose. Therefore, the first step is preparing a clean Docker Engine and Compose plugin environment on CentOS Stream 9.
1. Install Docker CE and Docker Compose Plugin
First, install the repository management utility, add the official Docker repository, and install the necessary packages:
# Install repository management utility
sudo dnf install -y dnf-plugins-core
# Add the official Docker CE repository for RHEL/CentOS
sudo dnf config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
# Install Docker engine, CLI, containerd, and Docker Compose plugin
sudo dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
# Start the Docker service and enable it at boot
sudo systemctl enable --now docker
# Verify the running Docker version
sudo docker version
2. Configure Firewalld
By default, Harbor uses two ports: HTTP (80) and HTTPS (443). Open these ports permanently in the firewall:
sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --permanent --add-service=https
sudo firewall-cmd --reload
3. Generate a Self-Signed SSL/TLS Certificate
By default, the Docker daemon refuses to communicate with registries over unencrypted HTTP. Rather than using the insecure insecure-registries option, you should configure HTTPS from the start. Here is how to generate a SAN (Subject Alternative Name) compliant self-signed certificate for the internal domain registry.internal.lan:
# Create certificate directory
sudo mkdir -p /data/cert && cd /data/cert
# Generate a private key for the internal CA
sudo openssl genrsa -out ca.key 4096
# Generate CA certificate valid for 10 years
sudo openssl req -x509 -new -nodes -sha512 -days 3650 \
-subj "/C=VN/ST=Hanoi/L=Hanoi/O=ITFromZero/CN=registry.internal.lan" \
-key ca.key -out ca.crt
# Generate private key for the Harbor server
sudo openssl genrsa -out server.key 4096
# Generate Certificate Signing Request (CSR)
sudo openssl req -sha512 -new \
-subj "/C=VN/ST=Hanoi/L=Hanoi/O=ITFromZero/CN=registry.internal.lan" \
-key server.key -out server.csr
# Create SAN configuration file to prevent validation errors in browsers and Docker
cat <<EOF | sudo tee v3.ext
authorityKeyIdentifier=keyid,issuer
basicConstraints=CA:FALSE
keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment
extendedKeyUsage = serverAuth
subjectAltName = @alt_names
[alt_names]
DNS.1=registry.internal.lan
DNS.2=registry
EOF
# Sign the Harbor server certificate with the newly created CA
sudo openssl x509 -req -sha512 -days 365 \
-extfile v3.ext \
-CA ca.crt -CAkey ca.key -CAcreateserial \
-in server.csr -out server.crt
Installing and Configuring Harbor
1. Download the Harbor Offline Installer
The offline installer package is approximately 800MB and bundles all required images. This makes the installation faster and more reliable than the online version:
cd /opt
sudo wget https://github.com/goharbor/harbor/releases/download/v2.10.0/harbor-offline-installer-v2.10.0.tgz
sudo tar -zxvf harbor-offline-installer-v2.10.0.tgz
cd harbor
2. Edit the harbor.yml Configuration File
Create a copy from the template and open the configuration file in a text editor:
sudo cp harbor.yml.tmpl harbor.yml
sudo vi harbor.yml
Update the domain, certificate paths, and administrator password as follows:
# Harbor server hostname or FQDN
hostname: registry.internal.lan
# HTTP port (used to redirect to HTTPS)
http:
port: 80
# HTTPS configuration with paths to the generated certificates
https:
port: 443
certificate: /data/cert/server.crt
private_key: /data/cert/server.key
# Default admin account password
harbor_admin_password: StrongPassword123!
# Storage location for images, database, and redis cache
data_volume: /data/harbor
3. Run the Installer Script with Trivy Scanner
Make sure to include the --with-trivy flag when running the installer script. This parameter pulls and configures the vulnerability scanner container:
sudo ./install.sh --with-trivy
Extracting images and initializing all 9 sub-services takes about 2 to 3 minutes. Once the terminal displays ----Harbor has been installed and started successfully.----, the system is ready to accept connections.
Production Operations & Security Testing
1. Trust the Certificate on Docker Clients
Without trusting the CA cert, the Docker client will throw an x509: certificate signed by unknown authority error when running the login command. Copy the ca.crt file into the trusted Docker directory on every worker or developer machine:
# Create certs.d directory matching the domain name
sudo mkdir -p /etc/docker/certs.d/registry.internal.lan/
# Copy the root CA file from the Harbor server
sudo cp /data/cert/ca.crt /etc/docker/certs.d/registry.internal.lan/ca.crt
# Reload Docker daemon configuration
sudo systemctl restart docker
If you don’t have an internal DNS server set up yet, you can temporarily map Harbor’s IP in the hosts file:
echo "192.168.1.50 registry.internal.lan" | sudo tee -a /etc/hosts
2. Log In and Push a Test Image
Log in to the registry using the admin credentials via terminal:
docker login registry.internal.lan
# Username: admin
# Password: StrongPassword123!
Now, pull the lightweight alpine:3.18 image, tag it, and push it to the default library project:
# Pull lightweight image from Docker Hub
docker pull alpine:3.18
# Tag image for Harbor Private Registry
docker tag alpine:3.18 registry.internal.lan/library/alpine:3.18
# Push image to internal registry over LAN
docker push registry.internal.lan/library/alpine:3.18
3. Scan for CVEs and Enable Automated Gatekeeping
Open your browser, navigate to https://registry.internal.lan, and log in with the admin account.
- Go to Projects > select library > switch to the Repositories tab.
- Click on the
alpinerepository to view the newly pushed3.18tag. - Select this tag and click the Scan button. Within 15 to 30 seconds, the Vulnerabilities column will detail any detected vulnerabilities categorized into 4 severity levels: Low, Medium, High, and Critical.
To completely prevent vulnerable images from reaching production, switch to the Project Settings tab and enable the following two policies:
- Prevent vulnerable images from running: Set the blocking threshold to High or Critical. Any
docker pulltargeting an image exceeding this threshold will be immediately rejected by Harbor with HTTP status code412 Precondition Failed. - Automatically scan images on push: Automatically triggers a security scan as soon as an image is pushed to the repository.
4. Monitor Harbor Service Health
During operations, check the overall health of the Harbor container cluster with:
cd /opt/harbor
sudo docker compose ps
All containers such as harbor-core, registry, nginx, harbor-db, and trivy-adapter should be in the Up (healthy) state. If you need to troubleshoot security scans, stream the logs in real time:
sudo docker compose logs -f trivy-adapter
This setup gives your team a private, self-hosted container registry that ensures complete data security and eliminates external bandwidth bottlenecks entirely.

