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.
Create the nezal folder.
nezal initreads your repository and writes a first draft for you to read and correct.nezal initPut 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/Give a service a URL.
nezal.yamlholds settings per service.public: truegives one a URL.version: 1 services: backend: public: trueSet the variables.
nezal.envholds values and templates. Every environment gets its own values.LOG_LEVEL=debug ENVIRONMENT={{ .Environment }} PUBLIC_URL={{ .Service.backend.URL }} API_KEY={{ .Secret.API_KEY }}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:
- 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.
- The image is pushed to the workspace's registry and tagged with the commit.
- 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.
- The database and the cache start first. Then the migrate commands run. Then the services
start in the order
dependsOngives.
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.
- Mark the service the address points at as public if the browser calls it.
- Under the built service add
build.argswith a template for each value that differs per environment. The templates are the onesnezal.envtakes. - 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.
- 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.
- Deploy the chart as it is. Every copy loses
nodeSelectorandnodeNameandtolerationsandaffinityandtopologySpreadConstraintsandpriorityClassName. - Open the environment page. A warning names each service and what was removed from it.
- If one service really needs its constraints on this cluster such as a worker that needs a GPU node set
scheduling: keepon it. - 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.
- Mark the service public.
- Under
ingress.annotationsadd the annotations your ingress controller reads. They are laid over the defaults your administrator set for the installation. - Push the branch and open the environment's URL. The annotations are on the environment's Ingress.
- Leave
ingress.annotationsout 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 ascert-manager.io/*orkubernetes.io/ingress.classorexternal-dns.*oralb.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.
Put the optional part in a folder of its own under
nezal/. Here the search engine and the worker that fills its index are innezal/search/.Name the folder under
profilesinnezal.yaml. A profile can list folders underdefinitionsand single services underservices. A profile name is lowercase letters and digits and dashes.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.Do not let a service outside the profile
dependsOna service inside it. Such an environment stops with a sentence such asbackend depends on opensearch which is in the profile search that this environment did not choose.Check both ways before you push. Nothing is sent anywhere.
nezal validate nezal validate --profile searchPush the branch.
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
searchand press create. The environment page shows the profiles it chose next to its branch.From the CLI. Choose the profile with
--profile. Repeat the flag to choose more than one.nezal up amr --profile search nezal status amrWithout
--profilethe environment runsbackendanddbonly.
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.
- Put the chart in a folder under
nezal/. A chart elsewhere in the repository is read too. Here the chart has a Deploymentapiand 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 }}
- 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
- Run init at the root of the repository:
nezal init
- Read what it drafted. The command is the Job's first container
commandfollowed by itsargs. A simple{{ .Values.x }}is looked up invalues.yaml. A variable the Job sets and the service does not goes undermigrate.env. A comment abovemigratenames 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"
Read every comment init wrote above
migrateand 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.envor undermigrate.envas{{ .Secret.NAME }}. - A variable whose name looks like a credential is written as
{{ .Secret.NAME }}. Set that secret withnezal secret set.
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.
- Keep your compose file at the root of the repository. Its name is
compose.yamlorcompose.ymlordocker-compose.ymlordocker-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:
- 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
- Read each drafted manifest. A compose service with
image:and nobuild:becomesnezal/<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:andexpose:. A service with no port gets no Service and init says so. entrypoint:becomes the containercommandandcommand:becomes itsargs.- A
CMDorCMD-SHELLhealthcheck 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
- Read
nezal.env. The composeenvironment: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 asdborredis:6379becomes{{ .Service.<name>.Host }}so it reaches that service's copy in the environment. The variables of a service compose builds go to the servicenezal.yamlbuilds 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
- Read
nezal.yaml. A dependency needs nothing there. A service it already lists getsdependsOnfrom the composedepends_on. A service compose builds is not drafted as a manifest becausenezal.yamlbuilds 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]
- 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.
- Add
migrate.commandto the service. - Under
migrate.envadd each variable asNAME: value. The migration gets the service's own variables first and these on top. - Use the templates of
nezal.envin 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 becausenezal.yamlis in git. - Push the branch. The migration Job carries the variables and the service does not. A change to
migrate.envruns 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
- Repositories. Connect the repository.
- Registries. Connect the registry the images are pushed to.
- Applications. Connect an application from the repository. Nezal finds the
nezalfolder 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. - 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. - 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
- Your environment only. The agent's tools are bound to your environment by the server before the model is asked anything. It cannot name another environment or a namespace or a pod outside yours. A colleague's environment in the same workspace is out of its reach.
- Only you. The agent of an environment is available to its owner and nobody else.
- It reads on its own. Status and pods and logs and events and variables.
- It asks before it changes anything. Running a command in a pod or restarting a service or redeploying from the branch or running a migration again. You see the exact action and approve or decline it. An approved action goes through the same permissions as you doing it yourself and runs a command only where the terminal is allowed.
- Everything is audited with your name and the model's.
- Secrets never reach the model. Credential-shaped text is removed from everything it reads and a secret variable shows by name only.
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
- A full copy deploys Deployments and StatefulSets with their Services and ConfigMaps and Secrets and volume claims. A chart's own Ingress and NetworkPolicy and ServiceAccount and Role and Job and CronJob are not copied and the environment says so. Nezal gives every public service its own URL and isolates each environment with its own policy.
- A Helm hook is not run. A one-off task such as a migration belongs under
migrate. - Values in a chart are rendered as they are written. A placeholder that your CI fills in such
as
${{ secrets.X }}is not filled in by Nezal. Use plain values in the chart you put innezal/and secrets for anything sensitive. - Idle sleep is off for a full copy because it has no router to see traffic. Its TTL still applies.
- Every environment in full mode runs the whole stack. Plan the cluster for one stack per developer and use the team's policy to limit how many run at once.