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:

  1. Install Harbor on the DR host
  2. In the primary Harbor UI: Registries → New Endpoint → add the DR Harbor
  3. 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

  1. 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.

  2. 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.

  3. 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.scanners concurrency to 2-3 in harbor.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.