ZTSSource →

REFERENCE.md ↓

ZTS — Reference

How to run ZTS, what the manifest can say, and what to expect. The model is in the manifesto; the normative detail is in the spec. This page is the practical side.

Install and first run

Only Docker is needed, on Linux or on a Mac with Docker Desktop.

docker run --rm -it -v zts:/etc/zts ghcr.io/spektral-tech/zts init
docker run -d --name zts --restart unless-stopped --privileged --pid=host --network host \
    -v /var/run/docker.sock:/var/run/docker.sock -v zts:/etc/zts ghcr.io/spektral-tech/zts up

init asks one question (coding agents, web development, or nothing) and writes zts.yaml into the zts volume. up starts the controller: it applies the manifest, then reacts to every container start and stop.

From then on, a managed container is any container started with --network none and one label:

docker run -d --name agent --network none --label zts.profile=dev alpine sleep infinity
docker exec agent wget -qO- https://api.github.com/zen

Why the controller container runs the way it does:

Docker Desktop with Enhanced Container Isolation turned on blocks --pid=host; ZTS stops with a clear message and does not try to work around it.

To upgrade or restart the controller, remove the old container first: docker rm -f zts, then the up line again. The manifest stays in the volume; the kernel state stays in place and is reconciled by the new controller.

Labels

zts.profile=<profile>     required: which profile of the manifest applies
zts.identity=<name>       optional: a stable identity; defaults to the container name

Identity decides the workload's address, which is derived from it and stable across restarts. Two running containers cannot share an identity.

The command line

The same commands exist in the image and in the standalone binaries.

zts init     [manifest]   write a manifest from a preset; asks once on a TTY, or use ZTS_PRESET
zts check    [manifest]   validate a manifest; no root, no Docker
zts edit     [manifest]   open it in $EDITOR; only a valid result is saved
zts show     [manifest]   print it
zts load     [manifest]   replace it from stdin, after validation
zts plan     [manifest]   show what apply would change; "No changes." when converged
zts apply    [manifest]   converge kernel state once and exit
zts status   [manifest]   workloads, transports, converged or drift
zts up       [manifest]   apply, then reconcile on container start/die and on manifest changes
zts destroy  [manifest]   remove all ZTS state; workloads fall back to no network
zts selftest              pure checks: allocation, names, presets

The manifest defaults to /etc/zts/zts.yaml when that directory exists, else ./zts.yaml.

Through the image:

docker exec zts zts status
docker exec zts zts plan
docker logs zts                     # every event and reconciliation
docker run --rm --privileged --pid=host --network host \
    -v /var/run/docker.sock:/var/run/docker.sock -v zts:/etc/zts ghcr.io/spektral-tech/zts destroy

Changing the manifest

The manifest lives in the zts volume, so these three commands exist so you never need to know where that is. The running controller notices a changed file within two seconds and applies it; an invalid file is reported in docker logs zts and the previous state stays applied.

docker exec -it zts zts edit

Opens the manifest in $EDITOR (falls back to vi) inside the running controller. On save it is validated; an invalid result is never written, you get to reopen or discard. The same works without a running controller: docker run --rm -it -v zts:/etc/zts ghcr.io/spektral-tech/zts edit.

docker run --rm -v zts:/etc/zts ghcr.io/spektral-tech/zts show > zts.yaml
docker run --rm -i -v zts:/etc/zts ghcr.io/spektral-tech/zts load < zts.yaml

For your own editor: show prints the manifest, load validates stdin and replaces it only if valid.

zts.yaml

version: 1

substrate:
  namespace: zts                    # the Linux namespace ZTS owns
  pool: 100.64.0.0/10               # addresses for workloads; a /30 per identity
  resolvers: [1.1.1.1, 9.9.9.9]     # upstream DNS for name-based resources

transports:
  host:
    type: host                      # leave through this host's default route
    allow_private: false            # keep private, link-local and multicast unreachable
  sandbox-egress:
    type: wireguard
    address: 10.255.255.2/32
    private_key_file: /etc/zts/sandbox-egress.key
    peer:
      public_key: ...
      endpoint: 192.0.2.10:51820
      allowed_ips: [0.0.0.0/0]
      persistent_keepalive: 25

resources:
  accounting-db:
    workload: accounting-db         # another managed container on this host
  github:
    names: [github.com, "*.github.com", "*.githubusercontent.com"]
  internet:
    cidrs: [0.0.0.0/0]
  office-printer:
    cidrs: [192.168.1.40/32]

profiles:
  isolated:
    allow: []
  dev:
    allow:
      - { to: accounting-db, protocol: tcp, ports: [5432] }
      - { to: github,        via: host, protocol: tcp, ports: [443] }

substrate

namespace names the Linux network namespace that holds every ZTS object. pool is carved into one /30 per identity, derived from the identity's hash, so there is no state file. resolvers are reached through the host transport.

transports

A transport is a way out. Declaring one grants nothing; a profile has to name it.

resources

A resource is a logical destination.

How names work without a proxy: the workload's only DNS server is its gateway. For each profile, that resolver answers only declared names and feeds the addresses it returns into a kernel set that the forwarding policy references. Undeclared names get NXDOMAIN. A raw address that was never returned for a declared name has no path. TLS stays end to end; there is no certificate to install and no environment variable to set.

profiles

A profile is a list of allow relations:

- { to: <resource>, via: <transport>, protocol: tcp|udp|any, ports: [..] }

via is required for CIDR and name resources, forbidden for workload resources. ports require tcp or udp. An empty allow is a workload with no network.

Scenarios

A coding agent that may reach GitHub, package registries and model APIs, port 443 only. The coding-agent preset. Everything else, including the host and the LAN, is unreachable.

Web development: any public name, web ports only. The web-dev preset: names: ["*"] via host on 80 and 443. Host, LAN and link-local stay closed.

Two workloads that talk to each other, nothing else.

resources:
  db: { workload: db }
profiles:
  app:
    allow:
      - { to: db, protocol: tcp, ports: [5432] }
  db:
    allow: []

Start db with --label zts.profile=db and the app with --label zts.profile=app. The relation is one-way: db cannot open connections to the app.

One LAN device, explicitly.

resources:
  printer: { cidrs: [192.168.1.40/32] }
profiles:
  print:
    allow:
      - { to: printer, via: host, protocol: tcp, ports: [9100] }

The private-space guard lets this through because the CIDR lies entirely inside private space and was named on purpose.

Everything through a remote gateway. Declare a wireguard transport and use via: <its name> on internet or on name-based resources. The gateway sees only that tunnel, never your LAN.

Something that must stay offline. isolated is in every preset: allow: []. The container has lo and an eth0 that leads nowhere.

What to expect

Running without the image

Go core

go build -o bin/zts ./cmd/zts
./bin/zts check zts.yaml
sudo ./bin/zts apply zts.yaml

Needs nft on the host, and iptables when Docker manages the host firewall (ZTS opens Docker's DOCKER-USER chain for its link).

Bash essentials

zts.sh is the readable reference: one file that does the most with the least. It is kept in parity with the Go core and passes the same acceptance suite.

Dependencies: bash 4+, curl, jq, Mike Farah yq v4, iproute2, nftables, nsenter, flock; dnsmasq built with nftset support for name-based resources; wireguard-tools for WireGuard transports.

./zts.sh selftest
./zts.sh check zts.yaml
sudo ./zts.sh apply zts.yaml
sudo ./zts.sh status zts.yaml
sudo ./zts.sh up zts.yaml

Read it top to bottom; every kernel object it creates is named there. Dockerfile.bash packages it with its tools, including a dnsmasq compiled with nftset.

Verifying a checkout

scripts/run-checks.sh            # lint, selftests, fixtures, go test, site; acceptance when root+nft+docker
sudo tests/acceptance/run.sh     # the spec's acceptance tests plus names, on Linux
scripts/two-minutes.sh           # the quickstart, timed, with a host-namespace cleanliness check

The acceptance suite runs against either implementation: ZTS_BIN=./bin/zts tests/acceptance/run.sh.