Last month our Artifactory renewal came in at 40% more than last year. No new features we needed, just the usual “enterprise tier” squeeze. Security scanning? Pay more. Replication? Pay more. SSO that isn’t SAML-only? You guessed it.
So I spent two weeks building a replacement. Here’s what actually worked, what didn’t, and the gotchas nobody warns you about.
What We Were Running
Our Artifactory setup handled:
- Docker images (~800 images, ~12TB total)
- npm packages (private registry, ~200 internal packages)
- Helm charts
- Generic binary artifacts (build outputs, firmware blobs)
The big requirements: vulnerability scanning on push, OIDC SSO, and cross-region replication to a DR site.
The Stack We Landed On
After a week of evaluating options, here’s what stuck:
| Role | Tool | Why |
|---|---|---|
| Docker + Helm | Harbor 2.12 | Trivy built-in, OIDC native, replication works |
| npm | Verdaccio 6 | Lightweight, proxy + private, just works |
| Generic blobs | Minio | S3-compatible, Harbor backend too |
| Reverse proxy | Caddy | Auto TLS, simpler than nginx for this |
Harbor Setup
Harbor was the obvious choice for containers. The install is straightforward but there are sharp edges:
# Download the offline installer - online installer pulls during install
# which is exactly what you don't want in prod
curl -LO https://github.com/goharbor/harbor/releases/download/v2.12.0/harbor-offline-installer-v2.12.0.tgz
tar xzf harbor-offline-installer-v2.12.0.tgz
cd harbor
cp harbor.yml.tmpl harbor.yml
The harbor.yml config that matters:
hostname: registry.internal.example.com
https:
port: 443
certificate: /etc/harbor/certs/registry.crt
private_key: /etc/harbor/certs/registry.key
# Use Minio as the storage backend
storage_service:
s3:
accesskey: ${MINIO_ACCESS_KEY}
secretkey: ${MINIO_SECRET_KEY}
region: us-east-1
regionendpoint: http://minio.internal:9000
bucket: harbor-registry
secure: false
# This is the part nobody tells you about
# Default is 5GB - our largest image layer was 8GB
proxy:
http_proxy:
https_proxy:
no_proxy: 127.0.0.1,minio.internal
components:
- core
- jobservice
- trivy
Gotcha #1: Harbor’s default storage driver has a 5GB layer limit. If you have ML images with huge layers (we had a CUDA image at 7.8GB per layer), you need to patch the registry config after install:
# After ./install.sh, edit this file
vim /data/harbor/registry/config.yml
# Add under storage.s3:
# chunksize: 10485760
# multipartcopymaxconcurrency: 8
# multipartcopythresholdsize: 67108864
Then restart the registry container. I lost four hours to this.
Gotcha #2: Trivy’s vulnerability database needs to be updated, and Harbor doesn’t auto-update it on air-gapped setups. We run this as a cron:
# Update Trivy DB - runs at 3 AM daily
0 3 * * * docker exec harbor-trivy trivy image --download-db-only 2>&1 | logger -t trivy-update
Verdaccio for npm
Verdaccio is criminally underrated. Five minutes to set up, handles private packages and proxies to npmjs.org:
# /opt/verdaccio/config.yaml
storage: /verdaccio/storage
auth:
htpasswd:
file: /verdaccio/htpasswd
max_users: -1 # Disable self-registration
# OIDC plugin for SSO
middlewares:
openid-connect:
issuer: https://auth.example.com/realms/devops
client-id: verdaccio
client-secret: ${VERDACCIO_OIDC_SECRET}
uplinks:
npmjs:
url: https://registry.npmjs.org/
cache: true
maxage: 30m
packages:
'@internal/*':
access: $authenticated
publish: $authenticated
unpublish: $authenticated
'**':
access: $all
publish: $authenticated
proxy: npmjs
docker run -d \
--name verdaccio \
-p 4873:4873 \
-v /opt/verdaccio:/verdaccio \
-e VERDACCIO_OIDC_SECRET="${OIDC_SECRET}" \
verdaccio/verdaccio:6
One thing I didn’t expect: Verdaccio’s proxy caching is good. After the initial pull, npm installs in CI dropped from ~45 seconds to ~8 seconds because everything was served locally.
Minio as the Backend
Minio is the glue. Harbor uses it as S3 storage, and we use it directly for generic binary artifacts:
# Install with docker compose
# minio-compose.yml
services:
minio:
image: minio/minio:latest
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
MINIO_IDENTITY_OPENID_CONFIG_URL: https://auth.example.com/realms/devops/.well-known/openid-configuration
MINIO_IDENTITY_OPENID_CLIENT_ID: minio
volumes:
- /data/minio:/data
ports:
- "9000:9000"
- "9001:9001"
For binary artifacts, we use mc (Minio client) in CI:
# In GitLab CI
upload_firmware:
stage: publish
script:
- mc alias set artifacts http://minio.internal:9000 ${MINIO_KEY} ${MINIO_SECRET}
- mc cp build/firmware-${CI_COMMIT_TAG}.bin artifacts/firmware/${CI_COMMIT_TAG}/
- mc tag set artifacts/firmware/${CI_COMMIT_TAG}/firmware-${CI_COMMIT_TAG}.bin "commit=${CI_COMMIT_SHA}" "pipeline=${CI_PIPELINE_ID}"
The Migration
This was the scary part. 800 images, zero downtime tolerance.
Step 1: Parallel Run
We ran both registries simultaneously for two weeks. CI pushed to both:
# In the Docker build job
docker tag ${IMAGE}:${TAG} artifactory.old.example.com/${IMAGE}:${TAG}
docker tag ${IMAGE}:${TAG} registry.internal.example.com/${IMAGE}:${TAG}
docker push artifactory.old.example.com/${IMAGE}:${TAG}
docker push registry.internal.example.com/${IMAGE}:${TAG}
Step 2: Bulk Copy with skopeo
For historical images, skopeo is the tool:
#!/bin/bash
# migrate-images.sh
set -euo pipefail
IMAGES=$(curl -s -u "${ARTIFACTORY_USER}:${ARTIFACTORY_TOKEN}" \
"https://artifactory.old.example.com/v2/_catalog" | jq -r '.repositories[]')
for image in ${IMAGES}; do
TAGS=$(curl -s -u "${ARTIFACTORY_USER}:${ARTIFACTORY_TOKEN}" \
"https://artifactory.old.example.com/v2/${image}/tags/list" | jq -r '.tags[]')
for tag in ${TAGS}; do
echo "Migrating ${image}:${tag}"
skopeo copy \
--src-creds "${ARTIFACTORY_USER}:${ARTIFACTORY_TOKEN}" \
--dest-creds "${HARBOR_USER}:${HARBOR_TOKEN}" \
"docker://artifactory.old.example.com/${image}:${tag}" \
"docker://registry.internal.example.com/${image}:${tag}" || echo "FAILED: ${image}:${tag}" >> failed.log
done
done
This took about 18 hours for our 12TB. The || echo pattern saved us — about 30 images failed (corrupt manifests in Artifactory that nobody noticed), and we could review them after.
Step 3: DNS Flip
Once everything was verified:
# Update internal DNS
# artifactory.old.example.com → stays on old IP (decomm later)
# registry.example.com → new Harbor IP
We also added a fallback pull-through cache in Harbor pointing to the old Artifactory, just in case we missed something. Removed it after a month when the access logs showed zero hits.
Replication
Harbor’s built-in replication to the DR site was almost too easy:
- Install Harbor on the DR host
- In the primary Harbor UI: Registries → New Endpoint → add the DR Harbor
- Replication → New Rule → event-based, replicate on push
It just worked. The one catch: make sure both Harbor instances use the same Minio bucket naming scheme, or you’ll end up with duplicate storage.
What I’d Do Differently
-
Start with Harbor’s Helm chart install, not the offline installer. The offline installer works but upgrades are painful. The Helm chart on Kubernetes is way cleaner.
-
Set up Minio replication first. We did Harbor replication and Minio replication separately, which meant some artifacts were replicated twice. Just let Minio handle the data replication.
-
Test the Trivy scanning load. On the first day, Harbor tried to scan all 800 images at once. The Trivy container hit 32GB of RAM and got OOMKilled. Set
trivy.scannersconcurrency to 2-3 inharbor.yml.
The Numbers
After one month:
- Cost: Went from ~$48k/year (Artifactory) to ~$2k/year (3 VMs + storage)
- CI speed: Docker push 20% faster (local network vs. SaaS)
- npm install: 45s → 8s in CI (Verdaccio proxy cache)
- Scanning: Trivy catches the same CVEs, actually runs faster
- Maintenance: ~2 hours/month (updates, certificate rotation)
The trade-off is obvious: you own the infra now. When Minio had a disk failure at 2 AM, that was on us. But with proper monitoring and the DR replica, it was a non-event — we replaced the disk the next morning.
Worth It?
If you’re a small team on the free tier, Artifactory is fine. If you’re paying enterprise prices and using maybe 30% of the features, look at your bill and ask what you’re actually getting.
For us, the two-week migration paid for itself in the first billing cycle. And now we control the stack. No more surprises in the renewal email.