Running our Docker registry on-prem with Harbor
As of early 2025, we’re deploying all of our applications with Kamal using Docker as our containerization platform. The container registry that holds our app images is one of the most integral pieces of our deployment pipeline.
Like many organizations, we’d been using external container registries for years. Our ecosystem was tightly coupled to both Dockerhub and Amazon’s Elastic Container Registry.
However, as part of our cloud exit and kamalization journey, several issues started emerging:
- Cost: Not only does the paid license for Dockerhub produce a considerable invoice — pulling and pushing our images over the internet dozens of times a day caused us to hit the contracted bandwidth limit with our datacenter provider Deft repeatedly. We tried working around this by running pull-through caches, but this still locked us to Dockerhub.
- Performance: Migrating HEY to Kamal and expanding the deployment to another continent caused deploy time penalties — up to 45 seconds on uncached pulls per host. This was exacerbated once our largest application Basecamp 4 was moved to Kamal — suddenly deployments took minutes longer simply because of push/pull speeds out of our control.
- Security and Governance: We all hope to never leak credentials in our images, and yet it still happens — the scale ranging from easily mitigated to catastrophic. We wanted to eliminate that threat surface once and for all by keeping our artifacts where they belong — with us.
- Independence: Despite being on a paid account, we fell into the crunch of API limitations for arbitrary reasons a couple of times. In addition, we’d been keeping all of the images used in our Chef CI/CD infrastructure still on AWS.
Our criteria for the solution to pick were fairly simple: reliable, performant, easy to set up, open-source.
We evaluated running the default distribution implementation as our registry, but quickly set our eyes on Harbor. Harbor provided us with a more expandable and rich feature set right out of the box, and required minimal extra tooling to make it robust and scalable.
Setting up Harbor#
Harbor’s deployment is optimized for using it within Kubernetes environments, but the single-server setup using the pre-packaged docker-compose configuration proved to be exactly what we were looking for.
We had three key points to cover in our plan for the v1 of our on-premise registry:
- Use our own S3 storage.
- Make sure we have at least two replicating sites that can be easily failed over.
- Keep the storage footprint as small as possible by enabling retention policies.
Configuring S3 storage#
At 37signals, we’re running our own Pure FlashBlade storage cluster providing us with S3 object storage right out of the box, but for Harbor, any S3-compatible backend will do.
The configuration in Harbor was easy, but it was crucial to get the permissions set right on the Pure backend. You can obviously run an s3:* policy, but let’s be real, we want to do better! After some trial and error with broken image pushes, these are the minimal permissions needed on the bucket to operate Harbor with a custom S3 backend:
s3:AbortMultipartUpload
s3:DeleteObject
s3:GetBucketLocation
s3:GetObject
s3:ListBucket
s3:ListBucketMultipartUploads
s3:ListMultipartUploadParts
s3:PutObject
Configuring multiple instances#
For the v1 of the Harbor deployment, we opted to run two stand-alone instances at first: one in our Ashburn and one in our Chicago location.
Harbor comes with several components, such as PostgreSQL and Redis services, handling manifest/user management and job scheduling. We explored an elaborate HA per datacenter with colocated instances of those services, but decided to wait for the first results of the all-in-one stand-alone deployment before making it more complicated than it has to be.
This is an excerpt of our harbor.yml in use, which gives you a functional instance, including the s3 configuration and enabled monitoring:
hostname: "#{node['fqdn']}"
http:
port: 80
data_volume: /data
harbor_admin_password: "#{admin_password}"
storage_service:
s3:
bucket: docker-registry-bucket
accesskey: "#{bucket_credentials['access_key']}"
secretkey: "#{bucket_credentials['secret_key']}"
regionendpoint: "https://purestorage.#{node["domain"]}"
region: us-east-1
encrypt: false
secure: true
v4auth: true
chunksize: 5242880
loglevel: debug
metric:
enabled: true
port: 9090
path: /metrics
database:
password: "#{db_password}"
max_idle_conns: 50
max_open_conns: 100
clair:
updaters_interval: 12
jobservice:
max_job_workers: 20
job_loggers:
- FILE
logger_sweeper_duration: 3600
log:
level: info
local:
rotate_count: 50
rotate_size: 200M
location: /var/log/harbor
notification:
webhook_job_max_retry: 3
webhook_job_http_client_timeout: 10
_version: 2.10.0
As you can see, it is a fairly default config. Encapsulated in a Chef recipe, this will be executed on the respective nodes in each DC, setting the correct FQDN and pointing to the correct storage endpoint. These nodes are then fronted by our F5 loadbalancers for SSL termination and region-specific domains.
Each Harbor node is currently a virtual machine equipped with 64GB of RAM, 32 vCPU and 320GB of storage.
Configuring replication#
The initial Chef setup only needs to run once for bootstrapping. For further configuration we decided to rely on the terraform provider for Harbor. In addition to the initial user management setup, here we could also configure replication between the endpoints easily. We decided on a two-way replication scheme to keep it all in sync, inspired by this setup.
- Images are pushed to a registry endpoint.
- The endpoint pulls data from the opposite registry every 10 minutes.
resource "harbor_replication" "replication_push_sc_chi" {
provider = harbor.sc-chi
name = "Replicate images on push to df-iad"
action = "push"
registry_id = harbor_registry.df_iad.registry_id
schedule = "event_based"
dest_namespace_replace = -1
filters {
name = "**"
}
filters {
tag = "**"
}
}
resource "harbor_replication" "replication_pull_sc_chi" {
provider = harbor.sc-chi
name = "Replicate missing images/artifacts from df-iad"
action = "pull"
registry_id = harbor_registry.df_iad.registry_id
schedule = "0 0/10 * * * *"
enabled = false
dest_namespace_replace = -1
filters {
name = "**"
}
filters {
tag = "**"
}
}
In addition, we’re replicating the underlying S3 buckets directly on our Pure cluster for an extra failsafe backup mechanism. It’s important to note however that this is not enough to make Harbor aware of the data in the other location — explicit replication on the Harbor level like the setup above must be configured.
Syncing the catalogue#
You could of course start with an empty registry and fill it as you go, but this isn’t very feasible if you want a drop-in replacement for your current registry. In our case, we had to make sure that the entire image catalog from Dockerhub gets copied into Harbor — the challenge being that this meant dealing with 80+ individual image repositories. Thankfully, Harbor offers replication directly from Dockerhub, so we opted for that.
Sounds straightforward? Here’s a funny caveat — depending on the amount of repositories you want to fetch and replicate, Dockerhub is likely going to throttle you on the API level if you want to do this all at once. You could totally write a functional replication rule that just targets **/**, only to be showered with 429 — even on a paid account.
Thus, the replication has to happen in batches. For this, we chose to create individual replication rules per repository with a “manual” (also scripted) trigger to avoid overloading the API.
The definition for the replication rules in Terraform:
variable "repositories" {
type = map(string)
}
resource "harbor_registry" "dockerhub" {
provider = harbor.sc-chi
provider_name = "docker-hub"
name = "DockerHub"
endpoint_url = "https://registry-1.docker.io"
description = "Endpoint for replicating the existing catalogue"
access_id = var.dockerhub_username
access_secret = var.dockerhub_password
}
resource "harbor_replication" "dockerhub_mirror" {
for_each = var.repositories
provider = harbor.sc-chi
name = "mirror-dockerhub-${each.key}"
description = "Replicate and mirror images from DockerHub"
registry_id = harbor_registry.dockerhub.registry_id
dest_namespace = "yourorg"
override = true
dest_namespace_replace = 1
copy_by_chunk = true
filters {
name = "yourorg/${each.key}"
}
filters {
tag = "**"
}
action = "pull"
}
The script to pull out a list of repositories from Dockerhub, make it accessible to Terraform and create the individual replication rules per repository:
dockerhub-to-harbor.sh
#!/usr/bin/env bash
# -----------------------------
# Config
# -----------------------------
DOCKERHUB_USER=""
DOCKERHUB_PASSWORD=""
DOCKERHUB_ORG="yourorg"
TFVARS_DIR="./generated_tfvars"
TFVARS_FILE="/all_repos.tfvars.json"
# -----------------------------
# Authentication
# -----------------------------
TOKEN=
if ; then
fi
# -----------------------------
# Helper Functions
# -----------------------------
# -----------------------------
# Execution
# -----------------------------
And the script to enable those rules alphabetically in batches:
trigger-replication.sh
#!/usr/bin/env bash
# -----------------------------
# Config
# -----------------------------
HARBOR_URL=""
HARBOR_USER=""
HARBOR_PASSWORD=""
BATCH_DELAY=10 # seconds between batches
PREFIX="mirror-dockerhub-"
# -----------------------------
# CLI Args
# -----------------------------
DRY_RUN=false
RANGE_START="a"
RANGE_END="z"
while ; do
case "" in
--dry-run)
DRY_RUN=true
;;
--range)
if ; then
RANGE_START=
RANGE_END=
else
fi
;;
*)
;;
esac
done
if ; then
fi
# -----------------------------
# Auth & Token
# -----------------------------
AUTH_HEADER="Authorization: Basic "
| || {
}
rules=
while ; do
id=
name=
# Filter by prefix first
if ; then
# Get letter after the prefix
suffix_letter=
if ; then
continue
fi
letter_to_ids[""]+=" "
fi
done
current=""
while ; do
ids=
if ; then
for; do
if ; then
else
fi
done
if ! ; then
fi
fi
current=
done
The progress of all replication tasks at the same time is quite hard to monitor within Harbor’s UI (despite excellent logging). Thus, another small script helped summarize this:
harbor-replication-monitor.sh
#!/usr/bin/env bash
HARBOR_URL=""
HARBOR_USER=""
HARBOR_PASSWORD=""
# Check required env vars
if ; then
fi
# -----------------------------
# Auth
# -----------------------------
pong=
if [; then
fi
# -----------------------------
# Fetch executions
# -----------------------------
executions=
# Cache for policy ID to name mapping
# -----------------------------
# Build table
# -----------------------------
rows=()
while ; do
id=
policy_id=
status=
start_time=
start_epoch=
now_epoch=
runtime_min=
policy_name=
row=
rows+=(" ")
done
# -----------------------------
# Print
# -----------------------------
for; do
done | |
After enabling those rules in batches, it’s also crucial to make sure enough job worker resources are available to sufficiently speed up this process.
Analyzing performance#
After migrating all our Kamal-ized apps to push and pull from our new on-premise registry, it was finally time to actually get some numbers on performance in. We grabbed this data directly from the deployment logs printed by Kamal.
You can use these quick one-liners for extracting the pull times from the Kamal log on your terminal:
# Mac
pbpaste | sort | sed 's/ INFO //' | grep -E "Running docker pull" -B1 | grep Finished | awk '{print $4}' | sort | tr '\n' ' '
# Linux
xclip -o | sort | sed 's/ INFO //' | grep -E "Running docker pull" -B1 | grep Finished | awk '{print $4}' | sort | tr '\n' ' '
Or from a logfile:
awk '
/Running .*docker pull/ {
if (match($0, /\[([a-f0-9]+)\]/, m)) {
id = m[1]
running[id] = $0
}
}
/Finished/ {
if (match($0, /\[([a-f0-9]+)\]/, m)) {
id = m[1]
if (id in running) {
print running[id]
print $0
print ""
delete running[id]
}
}
}
' /path/to/kamal.log
After analyzing the numbers, we were quite happy to see that:
- The overall image pull timings on our fleet decreased by up to 25 seconds for HEY, Basecamp 4 and Basecamp 2 (our three largest apps), with the lion’s share of improvement on our HEY nodes on the Amsterdam outposts.
- Deploy times decreased by 15 seconds for HEY.
In addition, it allowed us to:
- Retire the Dockerhub cache setup, further detangling our infrastructure.
- Implement proper retention policies and garbage collection to decrease the overall storage quota from almost 9 TiB to 1.5 TiB.
- Save roughly $5k/year on subscription fees going forward.
Remember: this is basically a single-node infrastructure, with the primary endpoint being in Chicago, and the Ashburn site providing the backup. We found that this small setup has been reliable for roughly two months now. During this time, Harbor has served more than 32,000 pulls under company-wide use in day-to-day business.
Conclusion#
This project proved to us that it’s — again — worth considering a departure from large SaaS offerings and public cloud providers. We’ve been dependent on external registries keeping our app images for years, but the simplicity and benefits of our current setup give little reason to doubt that cutting the cord was the right decision: better performance at less cost with minimal infrastructure.