Expose Harbor with Envoy Gateway

Overview

Envoy Gateway is a Gateway API-based L7 ingress and load balancer built on Envoy Proxy. In environments that do not provide an IngressClass — or where external traffic routing prefers Gateway API (for example, on Alauda Container Platform 4.3+ with the Envoy Gateway Operator) — Harbor cannot rely on Ingress resources, because Envoy Gateway does not process the Kubernetes Ingress API.

Instead, Harbor is exposed through an HTTPRoute (Gateway API). The Harbor Helm chart renders the HTTPRoute natively when expose.type is set to route, attaching it to a user-created Gateway managed by Envoy Gateway.

Architecture

External Client (docker / browser) https://harbor.example.com


DNS → Envoy Gateway external address


Gateway (Gateway API, HTTPS listener, TLS termination) [L7]


HTTPRoute: /api/ /service/ /v2/ /c/ → harbor-core:80
           /                        → harbor-portal:80


Harbor ClusterIP Services (harbor-core, harbor-portal)
  • TLS is terminated at the Gateway listener, not by Harbor's Nginx.
  • The Harbor Registry API (/v2/) is routed to harbor-core, so docker login / push / pull work through the Gateway.

Prerequisites

  1. Envoy Gateway is installed on the cluster.
    • On Alauda Container Platform 4.3+, install it through the platform's Envoy Gateway Operator. Creating the default EnvoyGatewayCtl instance (cpaas-default in the envoy-gateway-operator namespace) automatically generates the GatewayClass envoy-gateway-operator-cpaas-default.
    • On other clusters, install upstream Envoy Gateway and create a GatewayClass yourself.
  2. A Gateway resource exists (see Step 1: Create the Gateway).
  3. The domain name resolves to the Envoy Gateway external address.
  4. For high availability deployments, the same external dependencies as the standard HA mode are required (HA Redis/PostgreSQL, HA load balancer, more than 2 nodes). See Harbor Instance Deployment.

Step 1: Create the Gateway

Create the Gateway in the same namespace as the Harbor instance to keep the deployment simple (no ReferenceGrant required). For a cross-namespace Gateway, see Cross-namespace Gateway.

apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: harbor-gateway
  namespace: <harbor instance namespace>
spec:
  gatewayClassName: envoy-gateway-operator-cpaas-default
  listeners:
    - name: https
      protocol: HTTPS
      port: 443
      tls:
        mode: Terminate
        certificateRefs:
          - kind: Secret
            name: harbor-gateway-tls
      allowedRoutes:
        namespaces:
          from: Same

Notes:

  • Replace gatewayClassName with the GatewayClass generated by your EnvoyGatewayCtl instance. The default on ACP 4.3 is envoy-gateway-operator-cpaas-default; GatewayClass names follow the <namespace>-<name> pattern of the EnvoyGatewayCtl.
  • The HTTPS listener requires a TLS certificate Secret in the same namespace as the Gateway (see TLS Configuration).
  • You can also create the Gateway from the Web Console: Networking → Gateway API Gateway, which automatically creates a companion EnvoyProxy resource.
  • If you also need plain HTTP access (e.g., for a registry without TLS), add an HTTP listener on port 80.

Step 2: Deploy Harbor

Option A: Use the Harbor High Availability (Envoy Gateway) Template

Select the Harbor High Availability (Envoy Gateway) template from the Harbor deployment wizard and fill in:

FieldValue
Domain NameHarbor domain, e.g. harbor.example.com
Gateway NameThe Gateway resource name, e.g. harbor-gateway
Gateway NamespaceLeave empty when the Gateway is in the same namespace as the Harbor instance; otherwise fill in the Gateway's namespace

Option B: Deploy from YAML

The equivalent Harbor instance configuration is:

apiVersion: operator.alaudadevops.io/v1alpha1
kind: Harbor
metadata:
  name: harbor-ha
  namespace: <harbor instance namespace>
spec:
  version: 2.14.3
  helmValues:
    externalURL: https://harbor.example.com
    expose:
      type: route
      tls:
        enabled: false
      route:
        parentRefs:
          - name: harbor-gateway
            namespace: <harbor instance namespace>
            sectionName: https
            group: gateway.networking.k8s.io
            kind: Gateway
        hosts:
          - harbor.example.com
    persistence:
      enabled: true
      persistentVolumeClaim:
        registry:
          storageClass: <storage class>
          accessMode: ReadWriteMany
          size: 10Gi
        jobservice:
          jobLog:
            storageClass: <storage class>
            accessMode: ReadWriteMany
            size: 1Gi
        trivy:
          storageClass: <storage class>
          accessMode: ReadWriteMany
          size: 5Gi
    # High availability replicas, external PostgreSQL/Redis and credentials follow the standard HA configuration.
    # See "Deploying from YAML" in the deployment guide.

After the instance is deployed, the operator renders an HTTPRoute (named harbor-route by default) that attaches to the referenced Gateway.

Step 3: Verify

  1. Check the HTTPRoute is accepted by the Gateway:

    kubectl get httproute -n <harbor instance namespace>
    kubectl get httproute harbor-route -n <harbor instance namespace> -o yaml

The route conditions should show Accepted: True and ResolvedRefs: True.

  1. Access the Harbor UI: https://harbor.example.com

  2. Verify the registry API with a Docker client (in a test environment without DNS, map the domain with --add-host):

    docker login harbor.example.com -u admin -p <password>
    docker pull busybox
    docker tag busybox harbor.example.com/library/busybox
    docker push harbor.example.com/library/busybox

TLS Configuration

In route mode, TLS is terminated at the Gateway, so the certificate is configured on the Gateway listener instead of on the Harbor instance:

  1. Create the TLS certificate Secret in the Gateway's namespace:

    apiVersion: v1
    kind: Secret
    metadata:
      name: harbor-gateway-tls
      namespace: <gateway namespace>
    type: kubernetes.io/tls
    data:
      tls.crt: <base64 encoded cert>
      tls.key: <base64 encoded key>
  2. Reference it in the Gateway's HTTPS listener (see Step 1).

  3. Keep Harbor's own expose.tls.enabled as false and set externalURL to https://<domain>.

When the certificate is renewed, update the Secret; the Gateway listener picks up the new certificate without redeploying Harbor.

Cross-namespace Gateway

If the Gateway lives in a different namespace than the Harbor instance:

  1. The Gateway listener must allow routes from the Harbor namespace, e.g.:

    allowedRoutes:
      namespaces:
        from: Selector
        selector:
          matchLabels:
            cpaas.io/project: <project name>
  2. Create a ReferenceGrant in the Gateway's namespace that permits the Harbor namespace:

    apiVersion: gateway.networking.k8s.io/v1
    kind: ReferenceGrant
    metadata:
      name: allow-harbor-route
      namespace: <gateway namespace>
    spec:
      from:
        - group: gateway.networking.k8s.io
          kind: HTTPRoute
          namespace: <harbor instance namespace>
      to:
        - group: gateway.networking.k8s.io
          kind: Gateway

Troubleshooting

SymptomPossible CauseResolution
HTTPRoute not acceptedWrong parentRefs name/namespace, or listener does not allow the route's namespaceVerify the Gateway exists, the gatewayClassName is correct, and allowedRoutes covers the Harbor namespace; create a ReferenceGrant for cross-namespace
Gateway has no external addressEnvoy Gateway data plane service stays Pending (no MetalLB / cloud LoadBalancer on bare-metal or air-gapped clusters)Expose the data plane via NodePort: create an EnvoyProxy with envoyService.type: NodePort and reference it from the Gateway, see Access without a LoadBalancer below
404 from the GatewayNo matching hostname/path ruleConfirm hosts matches the requested domain and the HTTPRoute is Accepted
TLS handshake failureCertificate Secret missing or in the wrong namespaceVerify the Secret exists in the Gateway's namespace and is referenced by certificateRefs
docker login fails with x509 errorHarbor externalURL uses a different scheme/domain than the Gateway routeKeep externalURL: https://<domain> consistent with the Gateway hostname

Access without a LoadBalancer

On bare-metal or air-gapped clusters without MetalLB or a cloud LoadBalancer, the Envoy proxy Service defaults to type: LoadBalancer and stays Pending, so the Gateway never gets an external address. Create an EnvoyProxy (referenced from the Gateway via infrastructure.parametersRef) and set its envoyService.type to NodePort, then access Harbor via https://<nodeIP>:<nodePort>.

This is a generic Envoy Gateway capability; see the Envoy Gateway documentation for the full EnvoyProxy configuration reference: