Installing Nezal

One Helm chart and one command. About fifteen minutes from nothing to a developer opening their first environment. Nezal runs entirely inside your cluster: once the install has pulled its images it makes no outbound call of any kind.

Before you start

You do not need a licence key or an account and you do not have to talk to anybody. The install below is complete and free for three developers.

Install

TOKEN=$(openssl rand -hex 16)
echo "$TOKEN"
helm install nezal oci://registry-1.docker.io/nezal/nezal --version 0.2.32 \
  --namespace nezal-system --create-namespace \
  --set ingress.domain=dev.example.internal \
  --set ingress.dashboard.host=nezal.example.internal \
  --set api.bootstrap.token="$TOKEN"

The first four values matter on every install. The rest have working defaults and are there to raise for a large repository or a busy team:

Value What it is
ingress.domain The wildcard domain preview URLs are built from. An environment becomes https://<env>--<app>.<domain>. Use a subdomain of its own with a wildcard DNS record *.<domain> pointing at the ingress.
ingress.dashboard.host The hostname of the dashboard and API such as nezal.example.internal.
api.bootstrap.token A one-off admin token for the very first sign-in before SSO is configured. The first line generates it and prints it so you can keep it. Every use of it is audited.
operator.maxTreeSizeMiB The largest repository the operator will read, in MiB. Default 1024. Raise it for a large monorepo. Above it the operator refuses the repository with a message that names the size rather than filling its cache and restarting.
operator.sourceCache.sizeLimit Disk for the operator's git clones. Default 8Gi. Raise it with operator.maxTreeSizeMiB for very large repositories.
operator.router.resources.requests.* and operator.router.resources.limits.* Every application's router by default. Empty keeps 50m CPU and 64Mi requested and a 256Mi memory limit. An application can set its own under Router size in its settings.
operator.resources.requests.cpu and operator.resources.requests.memory What the operator is guaranteed. Default 50m CPU and 192Mi.
operator.resources.limits.cpu and operator.resources.limits.memory The most the operator may use. Default no CPU limit and 1Gi. It clones repositories, so memory is the one to raise for a large monorepo.
api.resources.requests.cpu and api.resources.requests.memory What the API and dashboard are guaranteed. Default 100m CPU and 256Mi.
api.resources.limits.cpu and api.resources.limits.memory The most the API may use. Default no CPU limit and 1Gi. Raise memory when many developers scan repositories at once.
registry.resources.requests and registry.resources.limits The bundled image registry when registry.enabled=true. Default 100m CPU and 256Mi requested with a 1Gi memory limit.
ingress.tlsSecret and ingress.dashboard.tlsSecret TLS Secrets holding the wildcard certificate for *.<domain> and the dashboard's certificate. With cert-manager use ingress.perHostTLS and ingress.annotations instead for a certificate per preview host. Without any of them preview URLs and the dashboard are served over plain HTTP. That is fine on a laptop and not fine anywhere else. See Preview URLs: DNS and TLS.
audit.retention.days Removes audit entries older than this many days once an hour so the audit log does not fill the data volume. Default 0 keeps every entry. Each run is written to the log and the entries left still verify. See Audit retention.
audit.retention.onlyArchived true removes only entries a signed archive already holds. Default false.

Watch it come up:

kubectl -n nezal-system get pods -w
helm test nezal -n nezal-system      # confirms the API answers from inside the cluster

Preview URLs: DNS and TLS

Every environment gets a hostname of its own. Nezal therefore needs one subdomain to itself and a wildcard certificate for it. DNS and certificates are yours and not Nezal's: the product issues no certificates and never contacts a certificate authority. Plan these before the install.

1. Choose a subdomain for preview URLs. Pick a name that belongs to Nezal alone such as dev.example.internal. An environment becomes <env>--<app>.dev.example.internal and the shared baseline <app>.dev.example.internal. Do not use your organisation's bare domain: environments would sit directly under your production domain and its certificate would have to cover them. This value is ingress.domain.

2. Choose a hostname for the dashboard such as nezal.example.internal. It can sit under the preview subdomain or beside it. This value is ingress.dashboard.host.

3. Point DNS at the ingress controller. Find the address your ingress controller answers on. Then create two records in whichever DNS your developers already use. Nothing about these names has to resolve from the internet.

kubectl get svc -A | grep -i ingress      # EXTERNAL-IP of the LoadBalancer, or the node IPs
Record Type Points at
*.dev.example.internal A (an IP) or CNAME (a name) the ingress controller
nezal.example.internal A or CNAME the ingress controller

4. Get the certificates. You need a certificate for the dashboard host and one that covers the preview URLs. Nezal issues neither and never contacts a certificate authority: it works with whatever your cluster already uses. Find your situation below and then continue at step 6.

You have You do Nezal setting
Your own certificates from a vendor or an internal authority Route 4a: put the wildcard in a Secret per workspace namespace ingress.tlsSecret
cert-manager Route 4b: nothing by hand. Certificates appear on their own ingress.perHostTLS and ingress.annotations
Traefik as the ingress controller Route 4b: nothing by hand either. Traefik obtains certificates itself and reads its own annotations and Nezal copies those onto every Ingress ingress.annotations with Traefik's resolver annotation
TLS ending on a load balancer or edge in front of the cluster Nothing inside the cluster. The certificate lives on the edge and it must cover *.<domain> for the preview URLs none. Set api.publicUrl to https://...
Nothing yet Route 4c: plain HTTP for an evaluation none

A word on the last two. When TLS ends outside the cluster nothing inside it can present a certificate. Neither a Secret nor cert-manager then changes what a browser sees: the edge's certificate has to name the preview hosts. And an HTTP-01 challenge needs port 80 open from the certificate authority to the host. An edge that only listens on 443 blocks that.

4a. Certificates you already hold

Your internal certificate authority is the usual source for names under a private domain. A public authority works for a domain you own. Load each one into a Kubernetes Secret.

The dashboard's Secret lives in the namespace Nezal is installed in. The wildcard Secret must be present in every application namespace because that is where the operator creates the preview Ingress objects. There is one application namespace per workspace.

kubectl create namespace nezal-system
kubectl -n nezal-system create secret tls nezal-dashboard-tls \
  --cert=dashboard.crt --key=dashboard.key

After you create a workspace in the dashboard add the wildcard certificate to its namespace. Until you do that workspace's baseline reports the condition IngressTLS=False and its preview URLs are served over plain HTTP.

kubectl -n <workspace-namespace> create secret tls nezal-preview-tls \
  --cert=wildcard.crt --key=wildcard.key

4b. cert-manager issues them for you

If cert-manager already runs on the cluster name the Secrets and let it fill them. Nezal only requires that the Secrets exist. Annotate the dashboard Ingress with the issuer and cert-manager creates the certificate the first time the Ingress appears.

kubectl get clusterissuer        # the issuers this cluster already has

Add these to the install in step 6 and replace the issuer name with yours:

  --set ingress.dashboard.tlsSecret=nezal-dashboard-tls \
  --set 'ingress.dashboard.annotations.cert-manager\.io/cluster-issuer=letsencrypt-prod' \

The backslash before .io is required: Helm treats an unescaped dot as a nesting separator.

The preview URLs work the same way with one certificate per environment. Add these two values and every preview Ingress the operator creates names a Secret of its own called <host>-tls. cert-manager fills it with a certificate for that one host the moment the Ingress appears. No wildcard and no DNS provider are involved and a new environment has its certificate within a minute. This is what a reverse proxy with built-in ACME does on a single machine. Here it is expressed in Kubernetes.

  --set ingress.perHostTLS=true \
  --set 'ingress.annotations.cert-manager\.io/cluster-issuer=letsencrypt-prod' \

With Traefik as your ingress controller instead of cert-manager the same values apply with Traefik's own annotation such as traefik.ingress.kubernetes.io/router.tls.certresolver=letsencrypt: the operator copies whatever annotations you give it and does not interpret them.

Two limits are worth knowing before you choose this route. An HTTP-01 issuer validates by answering a request on port 80 at each host. The hosts must therefore be reachable from the certificate authority and that rules it out on an isolated network. And public authorities rate-limit new certificates per registered domain: fifty a week at Let's Encrypt. A team that creates dozens of environments a day should use a wildcard instead. With a cert-manager DNS-01 provider for your DNS one Certificate per workspace namespace issues the wildcard into the Secret named by ingress.tlsSecret and perHostTLS stays off:

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: nezal-preview-tls
  namespace: <workspace-namespace>
spec:
  secretName: nezal-preview-tls
  dnsNames: ["*.dev.example.internal"]
  issuerRef: {name: letsencrypt-dns, kind: ClusterIssuer}

Either way each baseline reports the condition IngressTLS: True once its certificate exists and CertificatePending while the issuer is still working.

4c. Nothing yet for an evaluation

Leave both Secrets unset. The dashboard and the preview URLs are then served over plain HTTP and each baseline reports IngressTLS=False with reason NoTLSSecret. Everything else works. This is fine on a laptop and not fine anywhere else because a developer signs in over it.

5. Choose an ingress class. Skip this if the cluster runs one ingress controller. If it runs more than one name the one that holds the DNS records from step 3 and pass it as --set ingress.className=<name> below.

kubectl get ingressclass

6. Install with the matching values. With cert-manager on the cluster this is the whole install: the dashboard and every preview URL get their certificates on their own.

helm install nezal oci://registry-1.docker.io/nezal/nezal --version 0.2.32 \
  --namespace nezal-system --create-namespace \
  --set ingress.domain=dev.example.internal \
  --set ingress.dashboard.host=nezal.example.internal \
  --set ingress.dashboard.tlsSecret=nezal-dashboard-tls \
  --set 'ingress.dashboard.annotations.cert-manager\.io/cluster-issuer=letsencrypt-prod' \
  --set ingress.perHostTLS=true \
  --set 'ingress.annotations.cert-manager\.io/cluster-issuer=letsencrypt-prod' \
  --set api.bootstrap.token="$TOKEN"

With certificates you hold from route 4a name the Secrets instead:

helm install nezal oci://registry-1.docker.io/nezal/nezal --version 0.2.32 \
  --namespace nezal-system \
  --set ingress.domain=dev.example.internal \
  --set ingress.dashboard.host=nezal.example.internal \
  --set ingress.dashboard.tlsSecret=nezal-dashboard-tls \
  --set ingress.tlsSecret=nezal-preview-tls \
  --set api.bootstrap.token="$TOKEN"

Check the result with curl -I https://nezal.example.internal. After the first environment is up open the preview URL the CLI prints. With cert-manager watch the certificate appear:

kubectl -n nezal-system get certificate -w

Using the bundled registry

For an evaluation you can install a registry inside the cluster with --set registry.enabled=true. Builds push to it and it serves HTTPS with a certificate from Nezal's own CA. Connect it under Registries with the address nezal-registry.nezal-system.svc:5000 and no credential.

A node pulls images with containerd, which resolves names with the node's own DNS and does not trust Nezal's CA. Do these steps once on every node or every built image fails with ErrImagePull:

# On a machine with kubectl
kubectl -n nezal-system get svc nezal-registry -o jsonpath='{.spec.clusterIP}'      # note the address
kubectl -n nezal-system get secret nezal-registry-tls -o jsonpath='{.data.ca\.crt}' | base64 -d > nezal-registry-ca.crt

# On every node, with the address from the first line
echo "<address> nezal-registry.nezal-system.svc" | sudo tee -a /etc/hosts
d=/etc/containerd/certs.d/nezal-registry.nezal-system.svc:5000
sudo mkdir -p "$d" && sudo cp nezal-registry-ca.crt "$d/ca.crt"
printf 'server = "https://nezal-registry.nezal-system.svc:5000"\n\n[host."https://nezal-registry.nezal-system.svc:5000"]\n  ca = "%s/ca.crt"\n' "$d" | sudo tee "$d/hosts.toml"
sudo systemctl restart containerd

containerd reads /etc/containerd/certs.d only when config_path points there under [plugins."io.containerd.grpc.v1.cri".registry] in /etc/containerd/config.toml. Most distributions set it. On k3s put the same address and CA in /etc/rancher/k3s/registries.yaml instead and restart k3s.

For anything beyond an evaluation connect the registry you already run. Its nodes already pull from it and none of this is needed.

Network

Every pod Nezal runs can reach the internet and be reached from it by default. That covers the API and the operator and every build and every preview environment. A registry or git server or identity provider on the internet then works with no address to configure. Two things hold whatever you set:

To close the internet on an air-gapped or strictly controlled cluster:

  1. Set the two values below and list the private networks Nezal needs under networkPolicy.egress.allowCIDRs. Your git server and registry and identity provider must be inside them.

    networkPolicy:
      internet: false        # the API and the operator and builds
      egress:
        allowCIDRs:
          - cidr: 10.0.0.0/8
    environments:
      internet: false        # preview environments
    
  2. Upgrade the release.

    helm upgrade nezal oci://registry-1.docker.io/nezal/nezal -n nezal-system --reuse-values -f network.yaml
    
  3. Open Registries and press Test on each registry. A registry the cluster cannot reach says so in words.

A team's Policy can still open an internal network for its environments with egressCIDRs.

First sign-in

Open https://nezal.example.internal — or without ingress:

kubectl -n nezal-system port-forward svc/nezal-api 8080:8080

Sign in with the bootstrap token. The dashboard shows a free install: three developers and every feature. Turn on audit recording next under Audit log > Recording and retention. A fresh install records nothing until an administrator turns it on and the Audit log page and the admin Overview say so until you do. Configure SSO when you are ready (Settings → Security). Once OIDC or SAML is configured and the API restarts the bootstrap token stops working.

Connect a repository and get an environment

Describe your application once with a nezal folder in the repository. The quick start takes five steps and every command is listed in the nezal folder guide.

  1. Repositories → Connect a repository. Give it a Git URL and a credential.

  2. Registries → Connect a registry. This is required: an application cannot be created until one is connected. Built images are pushed there and pods pull with its credential.

  3. Applications → Connect an application. Nezal reads the repository and proposes the application definition from Helm charts or Kustomize or plain manifests. It shows which file each conclusion came from.

  4. Promote a baseline. This is the shared copy of the whole application that every environment routes back to.

  5. Download the CLI from nezal.dev. Admin → Installation shows the command for each platform. A machine with no internet downloads the same binary from your installation at https://nezal.example.internal/downloads/nezal-linux-amd64 instead:

    curl -fsSL https://nezal.dev/downloads/nezal-linux-amd64 -o /tmp/nezal
    chmod +x /tmp/nezal
    sudo mv /tmp/nezal /usr/local/bin/
    
  6. Create an environment:

    nezal login --server https://nezal.example.internal
    nezal up --name my-feature --ref feat/my-branch
    

    Nezal diffs the branch against the baseline and redeploys only the services that changed. Then it prints a preview URL.

Getting a licence

A free install is three developers with every feature and no key to request: the administrator and two more people. Two invitations are accepted and the same limit holds for people signing in through SSO. To add developers email [email protected]. A licence is USD 499 per month invoiced annually as USD 5988 per year for unlimited developers on one cluster.

Send your cluster id with the request. It is the UID of the kube-system namespace and it is also shown on Admin → Licence with a copy button:

kubectl get namespace kube-system -o jsonpath='{.metadata.uid}'

The key you receive records that id. It is not enforced: if you rebuild the cluster the id changes, your existing key still installs, and the mismatch is written to the audit log rather than refused. That is deliberate, so a rebuild during an incident never waits on us. Running Nezal on a second cluster means a second licence, which the audit log and the annual usage export make visible.

What comes back is a signed file rather than an account — paste it into Admin → Licence. It is verified locally against a public key compiled into the operator. It therefore works in a sealed room and nothing is sent anywhere to check it.

A licence runs for one year. When it lapses there is a 30-day grace period in which everything keeps working. You are warned from 30 days before expiry by email and through a Prometheus gauge and a Kubernetes Event as well as in the dashboard and the CLI. After the grace period the install returns to the free three developers: nothing is deleted and no running environment is stopped or scaled down. Previews stay open and the audit log stays exportable. Renewing restores every seat on the next request.

If the cluster cannot reach Docker Hub

Nezal pulls its images once at install time and then talks to nothing outside your network. If even that first pull is impossible mirror the images into your own registry and set global.imageRegistry:

helm install nezal oci://registry-1.docker.io/nezal/nezal --version 0.2.32 \
  --set global.imageRegistry=registry.example.internal/mirror \
  --namespace nezal-system --create-namespace

kubectl get pods -n nezal-system -o jsonpath='{..image}' lists everything an install pulls. For a site with no internet path at all contact [email protected]: there is a packaged offline bundle and it is worth doing together the first time.