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.
deployment Store
serves StoreApi
target linux
reverse proxy nginx
domain "store.example.com"
port 8080
tls
replicas 3
end
| Line | Means | Default |
|---|---|---|
serves ServiceName | the service this deployment runs (required) | — |
target linux / windows | the host platform | linux |
reverse proxy nginx / caddy / none | the proxy in front of the app | none |
domain "…" | the public domain | wildcard |
port N | the first port a replica listens on | 8080 |
tls | terminate HTTPS at the proxy and redirect HTTP | off |
replicas N | how many processes serve behind the proxy | 1 |
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/:
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.
# 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:
systemctl enable --now storeapi@8080 storeapi@8081 storeapi@8082
The container half is one command:
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:
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.
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.
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.
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, :8082 | unreachable |
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
$ 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.
# zornux.config.production.zxcfg
bind_address is "127.0.0.1"
# secret — provide 'api_key' out of band.
api_key is ""
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.