Installing Harbor Container Registry on AKS with Terraform, Traefik, and cert-manager

Installing Harbor Container Registry on AKS with Terraform, Traefik, and cert-manager

Provision an AKS cluster with Terraform and install Harbor behind Traefik, with cert-manager issuing Let's Encrypt certificates through Azure DNS DNS-01 and Workload Identity.

Why Self-Host a Registry on AKS?

Harbor is an open-source, CNCF-graduated container registry that stores, signs, and scans container images (and other OCI artifacts like Helm charts). Beyond the basic push/pull functionality of a registry, Harbor adds role-based access control, vulnerability scanning, image signing, replication, and quota management out of the box.

Two earlier posts on this blog covered operating Harbor once it is already running: forwarding its audit logs to Azure Log Analytics and monitoring it with Azure Monitor managed Prometheus and Grafana. Both assumed Harbor was already installed. This post fills that gap: it provisions the AKS cluster and installs Harbor from scratch, and wires in both of those companion posts from day one — Harbor’s audit-log forwarder and its Azure Monitor ServiceMonitor are part of the base install, not an afterthought.

The full, working reference implementation for this post is on GitHub: kasunsjc/Code-Snippets — AKS-Harbor-Registry-Demo. It contains the Terraform, the Helm value templates, the deploy.sh/cleanup.sh scripts, and the Traefik/cert-manager manifests referenced throughout this post — clone it and run ./deploy.sh instead of copy-pasting commands one at a time.

What We Are Building

flowchart TB
    User["Browser / docker CLI"] -->|HTTPS| Traefik["Traefik<br/>LoadBalancer Service"]

    subgraph AKS["AKS Cluster (Workload Identity + OIDC issuer)"]
        Traefik --> IngressRoute["Traefik IngressRoute<br/>+ redirect/HSTS Middleware"]
        IngressRoute --> HarborCore["harbor-core (ClusterIP, HTTP only)"]
        CertManager["cert-manager<br/>(Workload Identity, no secret)"] -.->|issues| Cert["harbor-tls Certificate"]
        Cert --> IngressRoute
        Forwarder["harbor-audit-forwarder<br/>(Fluent Bit)"]
        HarborCore -->|audit syslog :10514| Forwarder
        HarborCore --> HarborSvc["registry / jobservice / portal / trivy<br/>internal Postgres + Redis"]
        HarborSvc --> PV["managed-csi<br/>Persistent Volumes"]
        AmaLogs["ama-logs DaemonSet"]
        AmaMetrics["ama-metrics<br/>(managed Prometheus)"]
        Forwarder -->|stdout| AmaLogs
        HarborSvc -->|/metrics| AmaMetrics
    end

    CertManager -->|DNS-01| AzureDNS[("Azure DNS Zone<br/>(existing)")]
    Traefik -.->|A record| AzureDNS
    AmaLogs --> LAW[("Log Analytics")]
    AmaMetrics --> AMW[("Azure Monitor Workspace")] --> Grafana[("Azure Managed Grafana")]

The end state:

  • An AKS cluster provisioned with Terraform — not a single long-lived az aks create — with Workload Identity and an OIDC issuer enabled, plus Container Insights and managed Prometheus wired in at creation time.
  • Traefik as the ingress controller, exposed through an Azure LoadBalancer Service, routing to Harbor with IngressRoute/Middleware custom resources rather than a plain Ingress.
  • cert-manager issuing a Let’s Encrypt production certificate through an Azure DNS DNS-01 solver, authenticated with Azure Workload Identity — no client secret stored anywhere.
  • An existing Azure DNS zone (this post does not create or delegate one) that already receives the automated A record for Harbor’s hostname.
  • Harbor installed via the official Helm chart as an internal ClusterIP service (TLS terminates at Traefik, not Harbor), persisted on the AKS managed-csi storage class, with its audit-log forwarder and Azure-native ServiceMonitor deployed as part of the same install.

Prerequisites

Before starting, make sure you have:

  • An Azure subscription with quota for at least 2–4 general-purpose VM nodes, and the Azure CLI installed and logged in (az login).
  • An existing Azure DNS zone (for example example.com) that is already delegated to Azure DNS — i.e. your domain registrar’s NS records already point at it. This post only adds an A record to that zone; it does not create or delegate one.
  • Terraform ≥ 1.6.0, kubectl, helm ≥ 3.8, and envsubst (part of gettext) installed locally.
  • Familiarity with basic Helm chart values.yaml overrides and Terraform plan/apply.

Step 1: Provision Azure Infrastructure with Terraform

Rather than a single long-lived az aks create, the reference repo provisions everything with Terraform: the AKS cluster (with Workload Identity and an OIDC issuer enabled), a Log Analytics workspace, an Azure Monitor workspace with its Data Collection Rule for managed Prometheus, an Azure Managed Grafana instance, and the user-assigned identity + federated credential + DNS Zone Contributor role assignment that cert-manager will use in Step 3. It reads your existing Azure DNS zone as a data source — it never creates or deletes that zone.

Copy the example variables file and fill in your own DNS zone and ACME contact email:

cp terraform/terraform.tfvars.example terraform/terraform.tfvars
# terraform.tfvars
dns_zone_name           = "example.com"
dns_zone_resource_group = "rg-dns-zones"
acme_email              = "you@example.com"
harbor_subdomain        = "harbor-demo"   # -> harbor-demo.example.com

project     = "harbor"
environment = "demo"
location    = "northeurope"

Then provision the infrastructure:

terraform -chdir=terraform init -input=false
terraform -chdir=terraform apply -auto-approve

This creates, among other things: the resource group and AKS cluster, Log Analytics workspace, Azure Monitor workspace + Grafana, and a generated (never-committed) Harbor admin password. Fetch the cluster credentials and a couple of outputs you will need shortly:

RESOURCE_GROUP="$(terraform -chdir=terraform output -raw resource_group_name)"
CLUSTER_NAME="$(terraform -chdir=terraform output -raw cluster_name)"

az aks get-credentials \
  --resource-group "$RESOURCE_GROUP" \
  --name "$CLUSTER_NAME" \
  --overwrite-existing

kubectl get storageclass

You should see managed-csi marked (default) — Harbor’s PVCs will use it in Step 5.


Step 2: Install the Traefik Ingress Controller

Add the Traefik Helm repository and install a pinned version into its own namespace, exposed through an Azure LoadBalancer Service:

helm repo add traefik https://traefik.github.io/charts
helm repo update

helm upgrade --install traefik traefik/traefik \
  --version 34.4.1 \
  --namespace traefik --create-namespace \
  --wait --timeout 5m

Wait for Azure to assign a public IP to the Traefik Service, then capture it — you will need it for the Azure DNS record in Step 8:

kubectl get svc traefik -n traefik --watch
TRAEFIK_IP="$(kubectl get svc traefik -n traefik \
  -o jsonpath='{.status.loadBalancer.ingress[0].ip}')"
echo "$TRAEFIK_IP"

Step 3: Install cert-manager with Workload Identity and an Azure DNS ClusterIssuer

cert-manager needs to prove ownership of your domain to Let’s Encrypt. Rather than an HTTP-01 challenge routed through the ingress (which needs DNS to already resolve to the cluster), this setup uses a DNS-01 challenge against Azure DNS, authenticated through Azure Workload Identity — no client secret is stored in the cluster or in Git.

Install cert-manager with its CRDs, wiring its ServiceAccount to the cert_manager_client_id Terraform output from Step 1:

helm repo add jetstack https://charts.jetstack.io
helm repo update

CERT_MANAGER_CLIENT_ID="$(terraform -chdir=terraform output -raw cert_manager_client_id)"

helm upgrade --install cert-manager jetstack/cert-manager \
  --version v1.16.2 \
  --namespace cert-manager --create-namespace \
  --set crds.enabled=true \
  --set "serviceAccount.annotations.azure\.workload\.identity/client-id=$CERT_MANAGER_CLIENT_ID" \
  --set-string "podLabels.azure\.workload\.identity/use=true" \
  --wait --timeout 5m

kubectl -n cert-manager rollout status deployment/cert-manager-webhook --timeout=120s

Create a ClusterIssuer that solves the challenge via Azure DNS, using the same identity Terraform granted DNS Zone Contributor on your zone. Render it from the Terraform outputs (this mirrors cluster-issuer.yaml.tpl + envsubst in the reference repo):

apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: ${ACME_EMAIL}
    privateKeySecretRef:
      name: letsencrypt-prod-account-key
    solvers:
      - dns01:
          azureDNS:
            subscriptionID: ${SUBSCRIPTION_ID}
            resourceGroupName: ${DNS_ZONE_RESOURCE_GROUP}
            hostedZoneName: ${DNS_ZONE_NAME}
            environment: AzurePublicCloud
            managedIdentity:
              clientID: ${CERT_MANAGER_CLIENT_ID}
export SUBSCRIPTION_ID="$(terraform -chdir=terraform output -raw subscription_id)"
export DNS_ZONE_NAME="$(terraform -chdir=terraform output -raw dns_zone_name)"
export DNS_ZONE_RESOURCE_GROUP="$(terraform -chdir=terraform output -raw dns_zone_resource_group)"
export ACME_EMAIL="$(terraform -chdir=terraform output -raw acme_email)"
export CERT_MANAGER_CLIENT_ID

envsubst < kubernetes-manifests/cluster-issuer.yaml.tpl | kubectl apply -f -
kubectl get clusterissuer letsencrypt-prod

The ClusterIssuer should report Ready: True — RBAC propagation for the new role assignment can take 30–90 seconds, so a first attempt showing an Azure DNS 403 is usually just a timing issue.


Step 4: Deploy the Harbor Namespace and Audit-Log Forwarder

This step has to happen before Harbor is installed, not after. Harbor’s core container is configured (in Step 5) to forward audit events to harbor-audit-forwarder.harbor.svc.cluster.local:10514 — if that Service doesn’t exist and isn’t Ready yet, core fails its startup checks and the pod sits in CrashLoopBackOff.

kubectl apply -f kubernetes-manifests/namespace.yaml
kubectl apply -f kubernetes-manifests/audit-log-forwarder.yaml
kubectl -n harbor rollout status deployment/harbor-audit-forwarder --timeout=180s

The forwarder itself is a small Fluent Bit sidecar that re-emits Harbor’s audit syslog stream to stdout, where Azure Monitor Container Insights (ama-logs) picks it up and ships it to Log Analytics as ContainerLogV2. The Harbor Audit Logs in Azure Log Analytics post covers the forwarder’s manifest, the Container Insights configuration, and the KQL to query the result in detail — apply azure-config/monitoring/container-azm-ms-agentconfig.yaml from the reference repo alongside it if you haven’t already enabled stdout collection with harbor included.


Step 5: Install Harbor via Helm

Harbor is installed as an internal ClusterIP Service — Traefik terminates TLS in Step 6, so Harbor itself only needs to speak plain HTTP. Persistence uses the AKS managed-csi storage class explicitly (rather than an empty/default storageClass), and updateStrategy: Recreate avoids a managed-csi gotcha: Azure Disk only supports ReadWriteOnce, so a RollingUpdate would leave the new registry/jobservice pod stuck in ContainerCreating waiting for the old pod’s volume to detach.

updateStrategy:
  type: Recreate

expose:
  type: clusterIP
  tls:
    enabled: false
  clusterIP:
    name: harbor
    ports:
      httpPort: 80
      httpsPort: 443

externalURL: https://${HARBOR_FQDN}

internalTLS:
  enabled: false

persistence:
  enabled: true
  resourcePolicy: 'keep'
  persistentVolumeClaim:
    registry:
      storageClass: managed-csi
      size: 50Gi
      accessMode: ReadWriteOnce
    database:
      storageClass: managed-csi
      size: 5Gi
      accessMode: ReadWriteOnce
    redis:
      storageClass: managed-csi
      size: 1Gi
      accessMode: ReadWriteOnce
    trivy:
      storageClass: managed-csi
      size: 5Gi
      accessMode: ReadWriteOnce
    jobservice:
      jobLog:
        storageClass: managed-csi
        size: 1Gi
        accessMode: ReadWriteOnce

# Azure Monitor managed Prometheus scrapes Harbor via its own ServiceMonitor
# (Step 7, azmonitoring.coreos.com/v1). Keep the chart's own ServiceMonitor
# disabled — it uses monitoring.coreos.com/v1, which Azure Monitor ignores.
metrics:
  enabled: true
  serviceMonitor:
    enabled: false

core:
  configureUserSettings: |
    {
      "project_creation_restriction": "adminonly",
      "token_expiration": 30,
      "session_timeout": 60,
      "robot_name_prefix": "robot$",
      "robot_token_duration": 30,
      "read_only": false,
      "notification_enable": true,
      "scanner_skip_update_pulltime": true,
      "audit_log_forward_endpoint": "harbor-audit-forwarder.harbor.svc.cluster.local:10514",
      "disabled_audit_log_event_types": "",
      "skip_audit_log_database": false
    }

trivy:
  enabled: true

database:
  type: internal

redis:
  type: internal

Render ${HARBOR_FQDN} from the Terraform output, helm lint the pinned chart against the rendered values, then install:

export HARBOR_FQDN="$(terraform -chdir=terraform output -raw harbor_fqdn)"
HARBOR_ADMIN_PASSWORD="$(terraform -chdir=terraform output -raw harbor_admin_password)"

envsubst < kubernetes-manifests/harbor-values.yaml.tpl > harbor-values.yaml

helm repo add harbor https://helm.goharbor.io
helm repo update
helm pull harbor/harbor --version 1.19.2 --untar --untardir .rendered

helm lint .rendered/harbor \
  --namespace harbor \
  -f harbor-values.yaml \
  --set-string harborAdminPassword="$HARBOR_ADMIN_PASSWORD"

helm upgrade --install harbor harbor/harbor \
  --version 1.19.2 \
  --namespace harbor \
  -f harbor-values.yaml \
  --set-string harborAdminPassword="$HARBOR_ADMIN_PASSWORD" \
  --wait --timeout 10m

The admin password comes from Terraform’s random_password resource (Step 1) — it’s marked sensitive and is never written to a committed file, only retrieved via terraform output -raw harbor_admin_password when needed.


Step 6: Issue the TLS Certificate and Traefik Routes

With Harbor’s ClusterIP Service up, create a standalone cert-manager Certificate and the Traefik IngressRoute/Middleware resources that terminate TLS and redirect HTTP to HTTPS:

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: harbor-tls
  namespace: harbor
spec:
  secretName: harbor-tls
  issuerRef:
    name: letsencrypt-prod
    kind: ClusterIssuer
  dnsNames:
    - ${HARBOR_FQDN}
  usages:
    - digital signature
    - key encipherment
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
  name: harbor-portal
  namespace: harbor
  labels:
    app.kubernetes.io/name: harbor
    app.kubernetes.io/component: portal
  annotations:
    kubernetes.io/ingress.class: traefik
spec:
  entryPoints:
    - websecure
  routes:
    - match: Host(`${HARBOR_FQDN}`)
      kind: Rule
      services:
        - name: harbor
          port: 80
      middlewares:
        - name: harbor-headers
  tls:
    secretName: harbor-tls
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
  name: harbor-http
  namespace: harbor
spec:
  entryPoints:
    - web
  routes:
    - match: Host(`${HARBOR_FQDN}`)
      kind: Rule
      middlewares:
        - name: redirect-to-https
      services:
        - name: harbor
          port: 80
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: harbor-headers
  namespace: harbor
spec:
  headers:
    sslRedirect: true
    stsSeconds: 31536000
    stsIncludeSubdomains: true
    stsPreload: true
    forceSTSHeader: true
    customRequestHeaders:
      X-Forwarded-Proto: 'https'
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: redirect-to-https
  namespace: harbor
spec:
  redirectScheme:
    scheme: https
    permanent: true
envsubst < kubernetes-manifests/harbor-certificate.yaml.tpl | kubectl apply -f -
envsubst < kubernetes-manifests/harbor-ingress-route.yaml.tpl | kubectl apply -f -

Step 7: Apply the Azure-Native ServiceMonitor for Metrics

Harbor now exposes Prometheus metrics on its Services (metrics.enabled: true from Step 5). Apply the Azure Monitor-compatible ServiceMonitor — using the azmonitoring.coreos.com/v1 API group Azure Monitor managed Prometheus actually watches, not the OSS monitoring.coreos.com/v1 the Harbor chart would otherwise create:

kubectl apply -f kubernetes-manifests/harbor-azure-monitor-servicemonitor.yaml

The Monitoring Harbor with Azure Monitor and Azure Managed Grafana post covers building a dashboard on top of this in Azure Managed Grafana.


Step 8: Point DNS at the Ingress

Because the DNS zone already exists and Terraform granted the cert-manager identity access to it, the A record itself can be automated too — no manual DNS provider step required:

DNS_ZONE_RESOURCE_GROUP="$(terraform -chdir=terraform output -raw dns_zone_resource_group)"
DNS_ZONE_NAME="$(terraform -chdir=terraform output -raw dns_zone_name)"
HARBOR_SUBDOMAIN="$(terraform -chdir=terraform output -raw harbor_subdomain)"

az network dns record-set a create \
  --resource-group "$DNS_ZONE_RESOURCE_GROUP" \
  --zone-name "$DNS_ZONE_NAME" \
  --name "$HARBOR_SUBDOMAIN" \
  --ttl 300

az network dns record-set a add-record \
  --resource-group "$DNS_ZONE_RESOURCE_GROUP" \
  --zone-name "$DNS_ZONE_NAME" \
  --record-set-name "$HARBOR_SUBDOMAIN" \
  --ipv4-address "$TRAEFIK_IP"

This only ever touches the single A record for Harbor’s hostname — the shared zone itself is never created, modified, or deleted by this walkthrough.


Step 9: Verify the Installation

Watch the pods come up in the harbor namespace:

kubectl get pods -n harbor --watch

All components (core, portal, registry, jobservice, trivy, database, redis, plus harbor-audit-forwarder) should reach Running. Confirm the certificate finished issuing:

kubectl get certificate -n harbor
kubectl describe certificate harbor-tls -n harbor

Log in with the Terraform-generated admin password:

HARBOR_URL="https://$(terraform -chdir=terraform output -raw harbor_fqdn)"
echo "$HARBOR_URL"
# macOS: open "$HARBOR_URL" | Linux: xdg-open "$HARBOR_URL" | Windows (PowerShell): start "$HARBOR_URL"
# admin / $(terraform -chdir=terraform output -raw harbor_admin_password)

Harbor Projects dashboard after logging in as admin over the custom domain, with the default library project visible

Confirm the registry itself works end to end with a real image push:

HARBOR_FQDN="$(terraform -chdir=terraform output -raw harbor_fqdn)"
docker login "$HARBOR_FQDN"

docker pull alpine:3.20
docker tag alpine:3.20 "$HARBOR_FQDN/library/alpine:3.20"
docker push "$HARBOR_FQDN/library/alpine:3.20"

Finally, confirm the two companion integrations came up correctly:

# Audit-log forwarder is receiving events (log in to Harbor at least once first)
kubectl logs deployment/harbor-audit-forwarder -n harbor --tail=20

# Azure-native ServiceMonitor exists and Azure Monitor can discover it
kubectl get servicemonitor.azmonitoring.coreos.com -n harbor

Troubleshooting

SymptomLikely CauseWhat to Check
harbor-core CrashLoopBackOff on first installAudit-log forwarder wasn’t Ready before Harbor was installedkubectl rollout status deployment/harbor-audit-forwarder -n harbor, then helm upgrade Harbor again once it’s ready
Certificate stuck PendingDNS-01 propagation delay, or the Workload Identity federated credential is misconfiguredkubectl describe certificate harbor-tls -n harbor, kubectl logs -n cert-manager deploy/cert-manager; confirm the federated credential subject matches system:serviceaccount:cert-manager:cert-manager
cert-manager gets a 403 from Azure DNSRBAC propagation delay (30–90s), or the identity is missing DNS Zone Contributor on the zoneRe-check after a minute; confirm the zone-scoped role assignment from Step 1 exists
Redirect loop, or Harbor reports an HTTPS backend errorHarbor-side TLS was left enabled while Traefik also terminates TLSConfirm expose.type: clusterIP, expose.tls.enabled: false, and internalTLS.enabled: false in the rendered values
Harbor PVCs stuck Pendingmanaged-csi missing, or ReadWriteOnce multi-attach during an upgradekubectl get storageclass; for upgrades, confirm updateStrategy.type: Recreate is set
No audit events in ContainerLogV2Container Insights excludes the harbor namespace, or stdout collection is disabledCheck the cluster’s Container Insights exclude_namespaces and containerlog_schema_version settings
harbor_up missing in GrafanaServiceMonitor not discoveredkubectl describe servicemonitor.azmonitoring.coreos.com harbor-azure-monitor -n harbor; confirm the port name and release/app labels match the Harbor Services

Cleanup

Uninstall the Helm releases, remove only the DNS record this walkthrough created (never the shared zone), then destroy the Terraform-managed resources:

DNS_ZONE_NAME="$(terraform -chdir=terraform output -raw dns_zone_name)"
DNS_ZONE_RESOURCE_GROUP="$(terraform -chdir=terraform output -raw dns_zone_resource_group)"
HARBOR_SUBDOMAIN="$(terraform -chdir=terraform output -raw harbor_subdomain)"

az network dns record-set a delete \
  --resource-group "$DNS_ZONE_RESOURCE_GROUP" \
  --zone-name "$DNS_ZONE_NAME" \
  --name "$HARBOR_SUBDOMAIN" \
  --yes

helm uninstall harbor -n harbor
helm uninstall cert-manager -n cert-manager
helm uninstall traefik -n traefik
kubectl delete namespace harbor cert-manager traefik --ignore-not-found

terraform -chdir=terraform destroy -auto-approve

Do not run this against a shared or production cluster without reviewing what it deletes first — the reference repo’s cleanup.sh adds a confirmation prompt before any of this runs.

Security Notes

  • The Harbor admin password is generated by Terraform (random_password), marked sensitive, and never written to a committed file — retrieve it only via terraform output -raw harbor_admin_password.
  • cert-manager authenticates to Azure DNS with Workload Identity (a federated credential tied to the AKS OIDC issuer), not a client secret, and is scoped to DNS Zone Contributor on the single DNS zone — not subscription-wide access.
  • terraform.tfvars should be .gitignored; only terraform.tfvars.example (placeholder values) belongs in source control.

Next Steps

With Harbor installed, TLS automated, and both companion integrations already wired in during Step 4 and Step 7, the natural follow-ups are the two posts this install now feeds directly:

A further enhancement reserved (but not enabled) in the reference repo’s Terraform variables is Microsoft Entra ID OIDC single sign-on for Harbor — terraform output -raw harbor_oidc_redirect_uri already gives the exact redirect URI needed to register an app registration for a follow-up post.

Closing Thoughts

None of the individual pieces here — Terraform, an ingress controller, cert-manager, and a Helm chart — are unusual on their own. What makes a self-hosted registry practical is wiring the handoffs correctly: the audit-log forwarder has to exist before Harbor’s core container starts, the DNS-01 solver has to reach the same zone the Workload Identity was scoped to, and the ingress class has to match between Traefik and Harbor’s routes. Automate those handoffs once with Terraform and a couple of shell scripts, and helm upgrade --install harbor on top of them becomes routine — repeatable the same way every time, including in a disposable demo cluster.

Further Reading

Found this helpful?
Back to all posts