Zornux docs
Get started Spec

Tooling

Deployment

A finished app still has to reach a server. You describe a deployment in Zornux, and the toolchain generates what an operator actually needs — service units, a reverse proxy configuration, a container image and Compose project, a production config template, and a manifest — deterministically. Nothing ever names the runtime underneath: you ship a Zornux app, not a runtime.

The deployment block

A deployment … end block describes how one app ships. Every line is contextual — deployment, serves, target, reverse proxy, domain, port, tls, and replicas are not reserved words, so they never collide with your names.

zornux
deployment Store
    serves StoreApi
    target linux
    reverse proxy nginx
    domain "store.example.com"
    port 8080
    tls
    replicas 3
end
LineMeansDefault
serves ServiceNamethe service this deployment runs (required)
target linux / windowsthe host platformlinux
reverse proxy nginx / caddy / nonethe proxy in front of the appnone
domain "…"the public domainwildcard
port Nthe first port a replica listens on8080
tlsterminate HTTPS at the proxy and redirect HTTPoff
replicas Nhow many processes serve behind the proxy1
Declarative and validated

The block only declares intent. A malformed block, an unknown target or reverse proxy, a missing serves, a duplicate name, or a bad port are all beginner-friendly errors.

Generating artifacts

zornux deploy <what> app.zx reads the block and writes the generated files under deploy/:

text
zornux deploy docker   app.zx   # Dockerfile + .dockerignore + compose.yaml
zornux deploy service  app.zx   # systemd unit (or a Windows service script)
zornux deploy proxy    app.zx   # an nginx server block or a Caddyfile
zornux deploy config   app.zx   # zornux.config.production.zxcfg (secrets blank)
zornux deploy manifest app.zx   # deployment.json
zornux deploy all      app.zx   # the whole bundle
zornux deploy status   app.zx   # ask a running pool how it is doing

If a project declares more than one deployment, name it: zornux deploy all app.zx Store.

Two ways to run it

The same deployment ships either as processes supervised by the operating system, or as containers. deploy all writes both, and you use the half you want.

text
# supervised by the host
storeapi@.service                  → /etc/systemd/system/
storeapi.nginx.conf                → /etc/nginx/sites-available/ + a symlink
zornux.config.production.zxcfg     → beside the app

# or as containers
Dockerfile / .dockerignore         → the application image
compose.yaml                       → N replicas + the proxy
proxy.nginx.conf                   → replicas reached by name on the network

deployment.json                    → what the deployment exposes

A unit is templated on its port, so a pool is three systemctl arguments rather than three files:

bash
systemctl enable --now storeapi@8080 storeapi@8081 storeapi@8082

The container half is one command:

bash
docker compose build && docker compose up -d

The container never names the runtime

The generated Dockerfile builds on the official Zornux runtime image, pinned to the release that generated the deployment — read from the toolchain's own version, never a moving tag:

text
FROM ghcr.io/zornux/runtime:1.8.0
WORKDIR /app
COPY --chown=zornux:zornux . .
ENV ZORNUX_BIND_ADDRESS=0.0.0.0
ENV ZORNUX_PROFILE=Production
EXPOSE 8080
HEALTHCHECK --interval=10s --timeout=2s --start-period=5s --retries=3 \
  CMD wget -qO- http://127.0.0.1:8080/live || exit 1
ENTRYPOINT ["zornux", "serve", "/app"]

The image is public — no docker login to build FROM it — and published for linux/amd64 and linux/arm64, carrying the same binaries the release tarballs contain. So a container deployment and a service deployment of one release run identical code.

Why these lines are what they are

A container binds every interface of its own namespace — the default, localhost, would only ever answer from inside the container. It selects the production profile, without which the generated settings file is read by nothing. It copies the app to the account that runs it, because a COPY runs as root whatever USER is set. And the health check probes /live, which the host answers itself, so a replica busy with a slow request is not mistaken for a dead one.

No port override reaches a container

Each container has its own ports, so nothing needs moving out of the way — and one port setting could not express a program that publishes two services anyway, since it cannot say which service it means.

What you supply

Three things the generator cannot know: the service account, the secrets, and the certificate.

bash
useradd --system --no-create-home --shell /usr/sbin/nologin storeapi
install -d -o storeapi -g storeapi /opt/storeapi
install -m 600 /dev/null /etc/storeapi/secrets.env   # ZORNUX_PAYMENT_KEY=…

The unit refuses to start until the account exists. That is the intended failure — the alternative is a service that silently runs as root.

Where a replica listens

A generated deployment binds 127.0.0.1. The replica ports are not reachable from the network at all; only the proxy is. The isolation is the socket itself, not a firewall rule — which remains worth having as defence in depth.

From another machine
https://store.example.com (:443)200
http://… (:80)301 to HTTPS
replica :8080, :8081, :8082unreachable

Which hostnames the app answers for is a separate setting — accepted_hosts — and a request for a host this deployment does not serve is refused with 421 in both topologies.

Checking a pool

text
$ zornux deploy status app.zx
StoreApi
  state:     degraded
  expected:  3
  running:   2
  draining:  0
  failed:    1
  :8080  running  serving
  :8081  failed   nothing listening
  :8082  running  serving

It probes /live, which the host answers itself, so a replica busy with a slow request is counted as running rather than timed out and called dead.

Secrets are never baked in

deploy config reads the app's configuration schemas and writes a production template: each non-secret setting gets its declared default (or a typed placeholder), and each secret is written as an empty value with a note. A real secret value is never written into a generated file — you supply it from your secret store.

zxcfg
# zornux.config.production.zxcfg
bind_address is "127.0.0.1"
# secret — provide 'api_key' out of band.
api_key is ""
And never into an image

The generated .dockerignore keeps the environment file out of the image, because COPY . . would otherwise place live credentials in a layer readable by anyone who can pull it. Secrets reach a container at run time, from env_file — visible to anyone with Docker-level privileges on that host, which is a different thing from being baked into a distributable artifact.

Related: the settings that decide where a host listens and what it answers for — Configuration.