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
- Kubernetes 1.28 or newer. Any conformant distribution — k3s or OpenShift or a sovereign-cloud managed offering. For an evaluation kind on a laptop is enough.
- An ingress controller with a subdomain for preview URLs. The subdomain needs a wildcard DNS record pointing at the ingress and a wildcard certificate. Preview URLs: DNS and TLS below walks through it.
- Helm 3.
- A default StorageClass unless you turn persistence off for a trial.
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:
- Preview environments never reach each other. Each environment reaches its own pods and the internet and nothing else inside the cluster. Not another developer's environment and not the Nezal system and not the nodes.
- The cloud metadata address is never reachable.
169.254.169.254hands out the node's credentials so every policy excludes it.
To close the internet on an air-gapped or strictly controlled cluster:
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 environmentsUpgrade the release.
helm upgrade nezal oci://registry-1.docker.io/nezal/nezal -n nezal-system --reuse-values -f network.yamlOpen 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.
Repositories → Connect a repository. Give it a Git URL and a credential.
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.
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.
Promote a baseline. This is the shared copy of the whole application that every environment routes back to.
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-amd64instead:curl -fsSL https://nezal.dev/downloads/nezal-linux-amd64 -o /tmp/nezal chmod +x /tmp/nezal sudo mv /tmp/nezal /usr/local/bin/Create an environment:
nezal login --server https://nezal.example.internal nezal up --name my-feature --ref feat/my-branchNezal 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.
What to read next
- The nezal folder: the quick start and every option and every command.
- Administration — SSO and RBAC and quotas and policy and SIEM forwarding and backups and disaster recovery and upgrades.
- Security whitepaper — what a security team will ask.
- Controls mapping — NCA ECC and SAMA CSF and ISO 27001.
- Sizing — measured numbers and what they do not support.
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.