The nezal folder

Put a folder named nezal at the root of your repository and Nezal deploys what is inside it. Each developer gets a complete environment of the application from their own branch. Every service is built from that branch and runs with its own database and cache. Nothing is shared with another developer.

A repository without a nezal folder is read the way it always was. The folder is how you tell Nezal exactly what to do.

Status. Everything on this page is available: the format and full-mode environments built and deployed from each developer's branch and secrets and the dashboard override and the CLI commands and the AI agent.

Quick start

Run every command in your repository.

  1. Create the nezal folder. nezal init reads your repository and writes a first draft for you to read and correct.

    nezal init
    
  2. Put your deployment files in it. One folder per service with a Helm chart or a kustomization or plain Kubernetes manifests.

    nezal/
    ├── nezal.yaml
    ├── nezal.env
    ├── backend/    # Helm chart or manifests
    └── database/
    
  3. Give a service a URL. nezal.yaml holds settings per service. public: true gives one a URL.

    version: 1
    services:
      backend:
        public: true
    
  4. Set the variables. nezal.env holds values and templates. Every environment gets its own values.

    LOG_LEVEL=debug
    ENVIRONMENT={{ .Environment }}
    PUBLIC_URL={{ .Service.backend.URL }}
    API_KEY={{ .Secret.API_KEY }}
    
  5. Check it and start. Secrets are stored in the cluster and never in git.

    nezal secret set shop API_KEY
    nezal validate
    git push
    nezal up
    

The rest of this page explains every option.

What goes in it

nezal/
  nezal.yaml       what to build and which services get a URL
  nezal.env        the variables each service is given
  backend/         a Helm chart
  frontend/        a Helm chart
  huey/            plain manifests
  redis/           plain manifests
  database/        plain manifests or a chart

Every folder inside nezal/ is one thing to deploy. Nezal works out what each one holds:

The folder holds Nezal treats it as
Chart.yaml a Helm chart
kustomization.yaml a Kustomize overlay
any .yaml or .yml or .json file plain Kubernetes manifests
none of these nothing to deploy and it is skipped

You can mix them. All of them deploy together as one application. nezal.yaml and nezal.env are both optional. A nezal folder that holds only charts deploys every chart in full mode with no extra variables.

One namespace per workspace

Every environment of a workspace runs in the workspace's own namespace. The workspace acme has the namespace acme and every environment is deployed there. Each object carries the environment in its name:

In your manifests In the environment amr In the environment test
backend backend-preview-amr backend-preview-test
database database-preview-amr database-preview-test
redis redis-preview-amr redis-preview-test

A public service answers on https://<service>-preview-<environment>.<domain>. With the preview domain dev.acme.test the backend of amr is https://backend-preview-amr.dev.acme.test.

Your charts do not change. An address in a chart that names another service of the application is pointed at the same environment's copy. http://collaboration-server-service:9600 becomes http://collaboration-server-service-preview-amr:9600. Every rewrite is listed on the environment page.

Network

Each environment gets a network policy of its own. It decides what the environment's pods can reach and what can reach them:

Traffic Allowed
Between the environment's own pods Yes
From and to the internet Yes
From the ingress controller Yes
DNS Yes
To and from another environment in the workspace or in any other workspace No
To the Nezal system and other namespaces and pods in the namespace that Nezal does not manage No
To the nodes and the cloud metadata address 169.254.169.254 No
To an internal network the admin lists under egressCIDRs in the Policy Yes

Internal means the private and shared and link-local and loopback ranges: 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 100.64.0.0/10 169.254.0.0/16 127.0.0.0/8 and their IPv6 equivalents. A company database or proxy on one of them is reached by adding its range to egressCIDRs. So is a public URL of the application itself when the load balancer has an internal address.

The cluster's network plugin enforces the policy. A plugin that ignores NetworkPolicy leaves only the names between environments.

A name longer than 63 characters is shortened at the front with a short hash so it stays unique. The environment name is always kept whole at the end. An environment name is at most 20 characters of lowercase letters and digits and dashes.

nezal.yaml

Everything is optional except version.

version: 1
mode: full

definitions:                 # optional. Default: every folder under nezal/
  - path: backend
    values: [values-dev.yaml]
    releaseName: backend
  - path: redis

services:
  backend:
    build:
      context: components/backend
      dockerfile: docker/Dockerfile
      target: runtime
      args: {PYTHON_VERSION: "3.12"}
    public: true
    port: 8000
    migrate:
      command: [python, manage.py, migrate]
    dependsOn: [database, redis]
    replicas: 1
    resources:
      requests: {cpu: 100m, memory: 256Mi}
      limits: {memory: 1Gi}
  huey:
    build: {from: backend}
    dependsOn: [backend]
  frontend:
    build: {context: components/frontend}
    public: true

storage:
  size: 2Gi

environment:
  ttl: 72h

entrypoint: frontend

version

Always 1. A file with any other value is refused with a message rather than read wrong.

mode

Value What an environment gets
full The default. Its own copy of every service including the database and the cache. Nothing is shared so one branch can never affect another developer. It costs about one whole stack per environment.
shared Copies only the services the developer changes. Everything else is served by one shared baseline of the application. Cheaper for a large stack. Two environments share the same database.

definitions

What to deploy. Leave it out to deploy every folder under nezal/. List it to choose some folders or to give a Helm chart its values.

Key Meaning
path A folder inside nezal/. It cannot leave the folder.
kind helm or kustomize or manifests. Detected when left out. When given it must match what the folder holds.
values Helm only. Values files relative to the chart and merged in order.
releaseName Helm only. The release name. Defaults to the folder name.

services

Settings for a service beyond its manifests. The key is the name of its Deployment or StatefulSet. A service name is lowercase letters and digits and dashes and at most 40 characters so the environment suffix still fits.

Key Meaning
build.context Build this service from the branch. The folder to build relative to the repository root.
build.dockerfile The Dockerfile relative to the context. Defaults to Dockerfile. With no Dockerfile at all Nezal builds with buildpacks.
build.target The Dockerfile stage to stop at.
build.args Build arguments. A value may hold the templates of nezal.env and is resolved for each environment before it builds. A secret is refused and so is a name that looks like a credential because build arguments are recorded in the image.
build.from Reuse the image another service builds. For a worker such as Huey that runs the same code as the backend. It takes nothing else and must name a service that is built itself.
public Give the service a URL.
port The port the URL reaches. Defaults to the service's first port.
migrate.command A command run as a Job with the service's image and variables before the service starts. It runs on every deploy.
migrate.env Variables for the migration Job only on top of the service's own. Values take the templates of nezal.env. A secret is a whole value {{ .Secret.NAME }} and a credential-looking name with a plain value is refused. A change runs the migration again.
dependsOn Services that have to be ready first. A circle is refused.
replicas How many copies run in an environment. From 0 to 10. Usually 1.
resources CPU and memory for the service in an environment. Environments are usually much smaller than production.
scheduling keep keeps the node selector and tolerations and affinity and topology spread and priority class of the service's chart in its copy. Left out they are removed and the environment says so. No other value is allowed.
ingress.annotations Annotations for the Ingress of a public service laid over the installation's defaults. Left out the annotations of the folder's own Ingress for the same Service are carried over less the ones that set a host or TLS or a class or reach outside the namespace.

A service built from the branch does not need an image in its chart. Nezal sets the image to the one it built for that commit.

storage

size is the size of every volume an environment claims. Defaults to 1Gi. A database keeps its data across a restart and across sleep. The volume is deleted with the environment.

environment

Defaults for this application's new environments.

Key Meaning
ttl How long an environment lives. For example 72h.
sleepAfterIdle Scale an environment to zero after this long with no traffic. For example 2h. Shared mode only because a full copy has no router to see traffic.

The team's policy still sets the maximum.

entrypoint

The service whose URL the environment page shows first. It must be public.

profiles

Optional groups of definitions and services. An environment deploys a group only when it chooses it. Everything not in any group always deploys.

Key Meaning
profiles.<name> An optional group. An environment deploys it only when it chooses it. The name is lowercase letters and digits and dashes and at most 63 characters.
profiles.<name>.definitions Folders inside nezal/ to deploy only with this profile. Each must be a definition of the folder.
profiles.<name>.services Services to run only with this profile. Their workload and Service and migration and build are left out without it. Each must be a service of the folder. One that only a Helm chart or a kustomization makes is a warning in nezal validate because it is known once the chart is rendered.

nezal.env

The variables each service is given. The same shape as the .env file your team already keeps next to its code.

# For every service
MINIO_TIMEOUT=6000
LOG_LEVEL=debug
BACKEND_URL={{ .Service.backend.URL }}
REDIS_URL=redis://{{ .Service.redis.Host }}:6379
DATABASE_URL=postgres://app:app@{{ .Service.database.Host }}:5432/app
DB_PASSWORD={{ .Secret.DB_PASSWORD }}
ENVIRONMENT={{ .Environment }}

# Only for the frontend. A value here wins over the same name above.
[frontend]
VITE_API_URL={{ .Service.backend.URL }}/api
LOG_LEVEL=warn

Syntax

Line Meaning
NAME=value A variable. export NAME=value works too.
[service] Every line after it applies to that service only.
# text A comment. An unquoted value also ends at #.
NAME="a\nb" Double quotes allow \n and \t and \" and \\.
NAME='{{ raw }}' Single quotes are literal and never read as a template.

A name set twice in the same section is refused. A value under a name that looks like a credential such as DB_PASSWORD is refused unless it is a secret reference. This file is in git and everyone who can clone the repository can read it.

Templates

A value may hold {{ }} and it is filled in for each environment after it is deployed.

Template In the environment amr
{{ .Environment }} amr
{{ .Owner }} who created it
{{ .Branch }} the branch it was created from
{{ .Commit }} the commit it runs
{{ .Namespace }} the workspace namespace
{{ .Domain }} the preview domain
{{ .Service.backend.Host }} backend-preview-amr
{{ .Service.backend.Port }} its first port
{{ .Service.backend.Internal }} http://backend-preview-amr:8000 for calls inside the cluster
{{ .Service.backend.URL }} https://backend-preview-amr.<domain> for a public service
{{ .Secret.NAME }} a secret. See below.

A mistake stops the deploy with a message naming it. A service that does not exist or .URL on a service that is not public is never turned into an empty value that looks like it works.

Secrets

Set a secret once for the application in the dashboard or with the CLI. Then refer to it as the whole value of a variable:

DB_PASSWORD={{ .Secret.DB_PASSWORD }}

The variable reads the value from a Kubernetes Secret when the container starts. Nezal never puts the value into the variable and never shows it on the environment page. A secret has to be the whole value. prefix-{{ .Secret.X }} is refused.

When the file changes

nezal.env is read from the environment's own branch. Push a change to it and within about two minutes the services whose variables changed are restarted with the new values. Nothing is rebuilt. nezal sync <name> reads the branch at once.

Building

For each environment Nezal builds every service with a build entry from the environment's branch:

  1. The build runs inside your cluster as a Job with no privileges. Rootless BuildKit builds a Dockerfile and buildpacks build a folder with no Dockerfile.
  2. The image is pushed to the workspace's registry and tagged with the commit.
  3. A service whose context has not changed reuses the image already built for that commit. So two environments on the same code never build it twice.
  4. The database and the cache start first. Then the migrate commands run. Then the services start in the order dependsOn gives.

A push to the branch rebuilds only the services whose code changed and redeploys them.

How to

Each guide below is one task from start to finish with an example you can copy.

Give a build the addresses of its environment

A frontend often bakes the address of its API into the bundle when it is built. Write that address as a template in build.args and every environment builds its own image pointing at its own backend.

  1. Mark the service the address points at as public if the browser calls it.
  2. Under the built service add build.args with a template for each value that differs per environment. The templates are the ones nezal.env takes.
  3. Push the branch. Each environment resolves the arguments before it builds. Two environments whose arguments come out the same share one image. Two whose arguments differ get one image each.
  4. Open the environment page. A template that names a service that does not exist stops the environment with the exact field and the reason.
version: 1
services:
  backend:
    public: true
    port: 8000
  frontend:
    public: true
    build:
      context: components/frontend
      args:
        VITE_API_URL: "{{ .Service.backend.URL }}/api"
        VITE_ENVIRONMENT: "{{ .Environment }}"
        NODE_VERSION: "20"
entrypoint: frontend
FROM node:20 AS build
ARG VITE_API_URL
ARG VITE_ENVIRONMENT
WORKDIR /app
COPY . .
RUN npm ci && npm run build

A secret is never a build argument. Build arguments are recorded in the image and anyone who can pull it can read them. {{ .Secret.NAME }} in build.args is refused. Give the secret to the running service in nezal.env instead.

Keep or drop where a copy is scheduled

A production chart often pins its pods to a node pool with a node selector or tolerations or affinity. An environment runs on whatever nodes your cluster has so Nezal removes these from every copy and says so on the environment page.

  1. Deploy the chart as it is. Every copy loses nodeSelector and nodeName and tolerations and affinity and topologySpreadConstraints and priorityClassName.
  2. Open the environment page. A warning names each service and what was removed from it.
  3. If one service really needs its constraints on this cluster such as a worker that needs a GPU node set scheduling: keep on it.
  4. Push the change. That service keeps what its chart wrote and every other service is still free to run anywhere.
version: 1
services:
  web:
    public: true
  ml-worker:
    scheduling: keep
# nezal/ml-worker/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata: {name: ml-worker}
spec:
  selector: {matchLabels: {app: ml-worker}}
  template:
    metadata: {labels: {app: ml-worker}}
    spec:
      nodeSelector: {accelerator: gpu}
      tolerations: [{key: gpu, operator: Exists, effect: NoSchedule}]
      containers: [{name: worker, image: "registry.example.test/ml-worker:1"}]

Tune the URL of a public service

Nezal writes one Ingress for every public service with its own host and TLS. Annotations let you tune how the ingress controller serves it such as the largest upload or a timeout.

  1. Mark the service public.
  2. Under ingress.annotations add the annotations your ingress controller reads. They are laid over the defaults your administrator set for the installation.
  3. Push the branch and open the environment's URL. The annotations are on the environment's Ingress.
  4. Leave ingress.annotations out and Nezal carries over the annotations of the Ingress in your folder that sends traffic to the same Service. It drops the ones that set a host or TLS or the ingress class or make something outside the namespace such as cert-manager.io/* or kubernetes.io/ingress.class or external-dns.* or alb.ingress.kubernetes.io/*. A warning on the environment lists every one it dropped.
version: 1
services:
  backend:
    public: true
    port: 8000
    ingress:
      annotations:
        nginx.ingress.kubernetes.io/proxy-body-size: 50m
        nginx.ingress.kubernetes.io/proxy-read-timeout: "120"

Annotations that choose a host or a certificate or a class or create a DNS record or a load balancer or a certificate are refused in nezal.yaml too. So are configuration snippets and references to a Secret in another namespace.

Deploy an optional part only when you ask for it

A profile is an optional group of definitions and services. An environment deploys what a profile lists only when it chooses that profile and everything that no profile lists is always deployed. Use it for a part of the application that most developers do not need such as a search engine or a set of workers.

  1. Put the optional part in a folder of its own under nezal/. Here the search engine and the worker that fills its index are in nezal/search/.

  2. Name the folder under profiles in nezal.yaml. A profile can list folders under definitions and single services under services. A profile name is lowercase letters and digits and dashes.

  3. Put every variable that names a service of the profile under the [section] of a service in the same profile. A service that gets a variable naming a service it does not run is not deployed and the environment says why. The other services still deploy.

  4. Do not let a service outside the profile dependsOn a service inside it. Such an environment stops with a sentence such as backend depends on opensearch which is in the profile search that this environment did not choose.

  5. Check both ways before you push. Nothing is sent anywhere.

    nezal validate
    nezal validate --profile search
    
  6. Push the branch.

  7. From the dashboard. Open Environments and press New environment. Pick the application and a branch. The review step lists the profiles as checkboxes and none is ticked. Tick search and press create. The environment page shows the profiles it chose next to its branch.

  8. From the CLI. Choose the profile with --profile. Repeat the flag to choose more than one.

    nezal up amr --profile search
    nezal status amr
    

    Without --profile the environment runs backend and db only.

The folder:

nezal/
├── nezal.yaml
├── nezal.env
├── backend/
│   └── backend.yaml
├── db/
│   └── db.yaml
└── search/
    ├── opensearch.yaml
    └── indexer.yaml

nezal/nezal.yaml:

version: 1
services:
  backend:
    public: true
    dependsOn: [db]
  search-indexer:
    dependsOn: [opensearch, db]
profiles:
  search:
    definitions: [search]

nezal/nezal.env:

DATABASE_URL=postgres://app:app@{{ .Service.db.Host }}:5432/app

[search-indexer]
OPENSEARCH_URL={{ .Service.opensearch.Internal }}

nezal/backend/backend.yaml:

apiVersion: apps/v1
kind: Deployment
metadata: {name: backend}
spec:
  selector: {matchLabels: {app: backend}}
  template:
    metadata: {labels: {app: backend}}
    spec:
      containers:
      - name: backend
        image: registry.example.test/shop/backend:1
        ports: [{containerPort: 8000}]
---
apiVersion: v1
kind: Service
metadata: {name: backend}
spec:
  selector: {app: backend}
  ports: [{port: 8000}]

nezal/db/db.yaml:

apiVersion: apps/v1
kind: StatefulSet
metadata: {name: db}
spec:
  serviceName: db
  selector: {matchLabels: {app: db}}
  template:
    metadata: {labels: {app: db}}
    spec:
      containers:
      - name: postgres
        image: postgres:16
        env:
        - {name: POSTGRES_USER, value: app}
        - {name: POSTGRES_PASSWORD, value: app}
        - {name: POSTGRES_DB, value: app}
        ports: [{containerPort: 5432}]
        volumeMounts: [{name: data, mountPath: /var/lib/postgresql/data}]
  volumeClaimTemplates:
  - metadata: {name: data}
    spec: {accessModes: [ReadWriteOnce]}
---
apiVersion: v1
kind: Service
metadata: {name: db}
spec:
  clusterIP: None
  selector: {app: db}
  ports: [{port: 5432}]

nezal/search/opensearch.yaml:

apiVersion: apps/v1
kind: Deployment
metadata: {name: opensearch}
spec:
  selector: {matchLabels: {app: opensearch}}
  template:
    metadata: {labels: {app: opensearch}}
    spec:
      containers:
      - name: opensearch
        image: opensearchproject/opensearch:2
        env:
        - {name: discovery.type, value: single-node}
        - {name: DISABLE_SECURITY_PLUGIN, value: "true"}
        ports: [{containerPort: 9200}]
---
apiVersion: v1
kind: Service
metadata: {name: opensearch}
spec:
  selector: {app: opensearch}
  ports: [{port: 9200}]

nezal/search/indexer.yaml:

apiVersion: apps/v1
kind: Deployment
metadata: {name: search-indexer}
spec:
  selector: {matchLabels: {app: search-indexer}}
  template:
    metadata: {labels: {app: search-indexer}}
    spec:
      containers:
      - name: indexer
        image: registry.example.test/shop/indexer:1

With --profile search the environment amr runs backend-preview-amr and db-preview-amr and opensearch-preview-amr and search-indexer-preview-amr. Without it only the first two run and [search-indexer] in nezal.env is not used.

A profile applies to an application in full mode. An environment chooses at most 10 profiles. A name the folder does not define stops the environment with a problem that lists the profiles it does define.

Draft migrate from a Helm hook

A chart often migrates its database in a Job that Helm runs as a pre-install or pre-upgrade hook. Nezal never runs a Helm hook so nezal init reads that Job and proposes the same command as migrate.command on the service that runs the same image.

  1. Put the chart in a folder under nezal/. A chart elsewhere in the repository is read too. Here the chart has a Deployment api and this hook Job:
# nezal/api/templates/migrate-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: {{ include "api.fullname" . }}-migrate
  annotations:
    "helm.sh/hook": pre-install,pre-upgrade
spec:
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migrate
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          command: ["python", "manage.py"]
          args: ["migrate", "--noinput"]
          env:
            - name: MIGRATION_TIMEOUT
              value: {{ .Values.migrations.timeout | quote }}
  1. Keep the values the Job reads in the chart's values.yaml:
# nezal/api/values.yaml
image:
  repository: registry.example.test/api
  tag: "1.4.2"
migrations:
  timeout: 300
  1. Run init at the root of the repository:
nezal init
  1. Read what it drafted. The command is the Job's first container command followed by its args. A simple {{ .Values.x }} is looked up in values.yaml. A variable the Job sets and the service does not goes under migrate.env. A comment above migrate names the file and the Job it came from:
# nezal/nezal.yaml as nezal init drafts it
version: 1
mode: full
services:
  api:
    build: {context: api}
    public: true
    # From the Helm hook Job api-migrate in nezal/api/templates/migrate-job.yaml.
    # Nezal runs it with this service's image and variables before the service starts.
    migrate:
      command: ["python", "manage.py", "migrate", "--noinput"]
      env:
        MIGRATION_TIMEOUT: "300"
  1. Read every comment init wrote above migrate and act on it:

    • An expression it could not look up stays in the command as written. Replace it with the real value.
    • A variable whose value it could not look up is left out of migrate.env. Add it by hand.
    • A variable the Job reads from a Secret or a ConfigMap is left out. Set it in nezal.env or under migrate.env as {{ .Secret.NAME }}.
    • A variable whose name looks like a credential is written as {{ .Secret.NAME }}. Set that secret with nezal secret set.
  2. Check the folder:

nezal validate

How the service is chosen. A Deployment or StatefulSet whose container image is the Job's image gets the migration. The same chart expression such as {{ .Values.image }} counts as the same image. When nothing matches and the chart has only one Deployment it gets the migration and a comment says so. A Deployment whose name is a template such as {{ include "api.fullname" . }} is named after its chart folder. That is the release name Nezal uses by default. Hooks that run after install or upgrade are never proposed.

Draft dependencies from docker-compose

A team that runs its stack with docker-compose already lists the database and the cache and the mail catcher beside its code. nezal init reads the compose file once to draft a manifest for each of them under nezal/ and their variables in nezal.env. Compose is only read to draft files. It is never deployed and nothing reads it after init. What runs in an environment is the manifests under nezal/ that you read and commit.

  1. Keep your compose file at the root of the repository. Its name is compose.yaml or compose.yml or docker-compose.yml or docker-compose.yaml:
# docker-compose.yml
services:
  api:
    build: ./api
    ports: ["8000:8000"]
    environment:
      DATABASE_URL: postgres://app:app@db:5432/app
      REDIS_URL: redis://redis:6379/0
      SMTP_HOST: mail
    depends_on: [db, redis, mail]
  db:
    image: postgres:16
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: app
      POSTGRES_DB: app
    volumes:
      - dbdata:/var/lib/postgresql/data
    ports: ["5432:5432"]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 5s
  redis:
    image: redis:7-alpine
    expose: ["6379"]
  mail:
    image: axllent/mailpit:v1.20
    ports: ["8025:8025", "1025:1025"]
volumes:
  dbdata:
  1. Run init at the root of the repository:
nezal init

It says what it wrote and what it skipped and which secrets to set:

Wrote nezal/nezal.yaml
Wrote nezal/nezal.env
Wrote nezal/db/db.yaml
Wrote nezal/mail/mail.yaml
Wrote nezal/redis/redis.yaml
The compose file was read only to draft these files. Compose is never deployed: docker-compose.yml
Built from the repository so no manifest was drafted for api
Set these secrets before the first environment:
  nezal secret set <app> POSTGRES_PASSWORD
  1. Read each drafted manifest. A compose service with image: and no build: becomes nezal/<service>/<service>.yaml:
    • A Deployment. Or a StatefulSet with a volume claim when the service mounts a named volume. The claim is mounted at the container path of the first named volume. A folder on your laptop is never mounted.
    • A Service on the container ports from ports: and expose:. A service with no port gets no Service and init says so.
    • entrypoint: becomes the container command and command: becomes its args.
    • A CMD or CMD-SHELL healthcheck becomes an exec readiness probe with its interval and timeout and retries.
# nezal/db/db.yaml
# Drafted by nezal init from docker-compose.yml. Compose is read once to draft this file and never deployed.
# Read it and correct it before you commit it. Its variables are in nezal/nezal.env under [db].
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: db
  labels:
    app.kubernetes.io/name: db
spec:
  serviceName: db
  replicas: 1
  selector:
    matchLabels:
      app.kubernetes.io/name: db
  template:
    metadata:
      labels:
        app.kubernetes.io/name: db
    spec:
      containers:
        - name: db
          image: "postgres:16"
          ports:
            - containerPort: 5432
              protocol: TCP
          readinessProbe:
            exec:
              command: ["sh", "-c", "pg_isready -U app"]
            periodSeconds: 5
          volumeMounts:
            - name: data
              mountPath: "/var/lib/postgresql/data"
              subPath: data
  volumeClaimTemplates:
    - metadata:
        name: data
      spec:
        accessModes: [ReadWriteOnce]
        resources:
          requests:
            storage: 1Gi
---
apiVersion: v1
kind: Service
metadata:
  name: db
  labels:
    app.kubernetes.io/name: db
spec:
  selector:
    app.kubernetes.io/name: db
  ports:
    - name: tcp-5432
      port: 5432
      targetPort: 5432
      protocol: TCP
  1. Read nezal.env. The compose environment: of each dependency is under a [<service>] section. A name that looks like a credential becomes {{ .Secret.NAME }} and its value is never copied. A value that names another compose service such as db or redis:6379 becomes {{ .Service.<name>.Host }} so it reaches that service's copy in the environment. The variables of a service compose builds go to the service nezal.yaml builds from the same folder:
# nezal/nezal.env
# Drafted by nezal init from the .env files in this repository.
# The variables in docker-compose.yml are drafted here too. Compose is read once and never deployed.
# A value may be fixed or a template filled in for each environment such as
# {{ .Service.backend.URL }} or {{ .Service.redis.Host }}. Secrets are set with nezal secret set.

[api]
DATABASE_URL=postgres://app:app@{{ .Service.db.Host }}:5432/app
REDIS_URL=redis://{{ .Service.redis.Host }}:6379/0
SMTP_HOST={{ .Service.mail.Host }}

[db]
POSTGRES_DB=app
POSTGRES_PASSWORD={{ .Secret.POSTGRES_PASSWORD }}
POSTGRES_USER=app
  1. Read nezal.yaml. A dependency needs nothing there. A service it already lists gets dependsOn from the compose depends_on. A service compose builds is not drafted as a manifest because nezal.yaml builds it from its Dockerfile:
# nezal/nezal.yaml
# Drafted by nezal init. Read it and correct it before you commit it.
# Every folder under nezal/ holding a Helm chart or a kustomization or manifests is deployed.
# The manifests drafted from docker-compose.yml are under nezal/ too. Compose is never deployed.
# See https://nezal.dev/docs/guide/nezal-folder/ for every option.
version: 1
mode: full
services:
  api:
    build: {context: api}
    public: true
    dependsOn: [db, mail, redis]
  1. Set each secret init listed and check the folder:
nezal secret set <app> POSTGRES_PASSWORD
nezal validate --env amr --domain dev.example.test

A file that already exists is never replaced and init says it was skipped. Run nezal init --force to replace it. A folder under nezal/ that already holds a Helm chart or a kustomization is always left alone.

Give the migration its own variables

A migration sometimes needs a setting the running service must not have such as a settings module for migrations or a database user that may change the schema. migrate.env gives variables to the migration Job only.

  1. Add migrate.command to the service.
  2. Under migrate.env add each variable as NAME: value. The migration gets the service's own variables first and these on top.
  3. Use the templates of nezal.env in a value when it differs per environment. A credential is a secret reference as the whole value such as "{{ .Secret.DB_ADMIN_PASSWORD }}". A name that looks like a credential with a plain value is refused because nezal.yaml is in git.
  4. Push the branch. The migration Job carries the variables and the service does not. A change to migrate.env runs the migration again.
version: 1
services:
  backend:
    public: true
    build: {context: components/backend}
    dependsOn: [database]
    migrate:
      command: [python, manage.py, migrate]
      env:
        DJANGO_SETTINGS_MODULE: app.settings.migrate
        DB_USER: schema_owner
        DB_PASSWORD: "{{ .Secret.DB_ADMIN_PASSWORD }}"
        MIGRATION_LABEL: "{{ .Environment }}-{{ .Commit }}"
nezal secret set backend-app DB_ADMIN_PASSWORD=change-me

From the dashboard

  1. Repositories. Connect the repository.
  2. Registries. Connect the registry the images are pushed to.
  3. Applications. Connect an application from the repository. Nezal finds the nezal folder and shows every service with how it is deployed and whether it is built and whether it is public. It also shows what each variable resolves to for an example environment.
  4. Environments. Pick the application and a branch and a name such as amr. The page shows the URLs before anything is created. Press create and follow each build.
  5. The environment page. Its URLs. Each variable with its value and where it came from. Logs and a terminal per service. Rebuild and delete.

You can also edit nezal.yaml in the dashboard. The edit is saved as an override on the application and laid over the file on every branch. The file in git stays the source. Copy the change into nezal.yaml and commit it once you are happy with it.

From the CLI

Command What it does
nezal login --server <url> Signs in to your installation through the browser. Press Approve in the browser and the terminal says it is signed in. The token is saved in ~/.nezal/config.json on your machine. If the browser shows the token instead paste it into the terminal.
nezal init [folder] Drafts nezal.yaml and nezal.env from the Dockerfiles and .env files in the repository. A pre-install or pre-upgrade Helm hook Job becomes migrate. Each compose service that runs an image becomes a manifest under nezal/. Each service built from a Dockerfile gets a Deployment and a Service under nezal/<service>/ with the port the Dockerfile exposes, which runs the image Nezal builds. A Helm chart elsewhere in the repository is pointed out so you can move it in instead. Compose is never deployed.
nezal validate [folder] Checks the nezal folder on your machine and shows the services and their URLs and every variable for one environment. Nothing is sent anywhere. Use --env and --domain to choose the example and --profile <name> to check it as an environment with that profile.
nezal up [name] Creates or updates your environment from the current branch and prints its URLs. --ref picks another branch. --ttl sets how long it lives. --profile <name> also deploys that profile of the nezal folder and can be repeated.
nezal list Your environments. --scope team or all shows more.
nezal status <name> Its services and builds and URLs and the profiles it chose.
nezal logs <name> Every pod and container with why it last stopped. Add -s <service> or --pod <pod> with -c <container> to stream one. -f follows. -p shows the run before the last restart. --grep <text> keeps matching lines. Only the owner reads logs.
nezal terminal <name> -s <service> Opens a shell in a container of your own environment. -c <container> picks the container.
nezal env <name> Every variable each service got.
nezal sync <name> Rebuilds what changed on the branch and redeploys it.
nezal extend <name> Keeps an environment longer.
nezal secret set|list|rm <app> NAME Manages the secrets that nezal.env reads. With no value the secret is read from standard input.
nezal agent <name> "<question>" Asks the AI agent about your environment. It asks before it changes anything.
nezal down <name> Deletes the environment and its data.
nezal app list The applications of the workspace and whether each runs in full or shared mode. Pass one of the names with --app.
nezal registry nezal config Registries and saved settings.
nezal version The version of the tool.
nezal <command> --help Every option of a command.

Run nezal validate before you push:

$ nezal validate --env amr --domain dev.acme.test
Mode: full
Deploys:
  nezal/backend  helm
  nezal/database  manifests
  nezal/redis  manifests
Services in environment amr
  backend  backend-preview-amr · https://backend-preview-amr.dev.acme.test · built from components/backend
  database  database-preview-amr
  huey  huey-preview-amr · uses the image of backend
  redis  redis-preview-amr
The nezal folder is valid.

A service that only a Helm chart or a kustomization defines is known once the chart is rendered and that happens in the cluster. nezal validate warns about a template naming such a service and does not fail.

The AI agent

Every environment page has an Assistant tab when an administrator has enabled a model under Admin. Ask it anything about the environment: why a service is restarting or whether the migration ran or what a variable resolved to. It works from the terminal too:

nezal agent amr why is the backend restarting

The model is the one an administrator configured under Admin. A model you run inside your cluster needs no internet at all. A hosted one reaches its own endpoint and no other host.

Limits