Example

apiVersion: keel/v1
kind: Jail
metadata:
  name: web-1
spec:
  image: base/14.2-web
  command: ["/usr/local/bin/myapp"]
  network:
    vnet: true
    bridge: keel0
    address: 10.0.0.5/24
  resources:
    cpu: "2"
    memory: "512M"
  restartPolicy: Always

Fields

FieldTypeMeaning
apiVersionstringSchema version, currently always keel/v1.
kindstringResource kind, currently always Jail.
metadata.namestringThe jail's spec name. Must pass Keel's name validation. Immutable: it identifies the jail for its whole lifetime; changing it means applying a new jail, not renaming this one. The real FreeBSD jail is named keel-<name>.
spec.imagestringWhich base ZFS dataset to clone the jail's root filesystem from. With the default pool zroot, image: base/14.2-web resolves to the dataset zroot/keel/base/14.2-web, which must already exist and be populated. Immutable after first apply; changing it means destroying and recreating the jail.
spec.commandlist of stringsThe command (and its arguments) to run inside the jail.
spec.network.vnetbooleanWhether the jail gets its own VNET network stack, decided at jail creation. Unlike spec.image and spec.network.address, the reconciler does not reject a changed value on re-apply, but it also never re-creates an existing jail to apply it, so a running jail keeps whatever vnet setting it was created with.
spec.network.bridgestringThe bridge(4) interface the jail's epair(4) pair attaches to.
spec.network.addressstring (CIDR)The static address assigned to the jail's side of the epair(4) pair, e.g. 10.0.0.5/24. Validated as a CIDR address. Immutable after first apply: attempting to change it on a re-apply is rejected with a 409.
spec.resources.cpustringCPU limit, parsed into an rctl(8) pcpu percentage (e.g. "2" means 2 cores' worth).
spec.resources.memorystringMemory limit, parsed into bytes for rctl(8) (e.g. "512M").
spec.restartPolicyAlways | OnFailure | NeverWhether the reconciler restarts the jail's command after it exits, and under what conditions.
spec.volumeslist of objectsNamed persistent volumes; see Volumes below. Defaults to an empty list, so specs written before this field existed keep parsing unchanged. Immutable after first apply: changing the list on a re-apply is rejected with a 409, delete and re-apply instead.

Fields not listed as immutable above can change on a re-apply of the same metadata.name; the reconciler diffs the new spec against the running jail's state and reconciles the difference.

Volumes

Each entry in spec.volumes declares one named ZFS-backed directory whose dataset is created the first time a jail referencing it is provisioned, survives that jail being deleted, and is only ever destroyed by an explicit, separate DELETE /volumes/<name> (see the HTTP API reference). Two jails referencing the same volume name on the same node share the same dataset; nothing coordinates concurrent writers, so single-writer is the only supported use today. A volume is single-node only, exactly the node the jail that first referenced it was scheduled onto; there is no cross-node volume movement yet. A kind: Service's template can declare volumes too (see below), in which case every replica is pinned to its first-placement node for life rather than rescheduled on node failure, exactly to avoid attaching node-local data to something that could otherwise move without it.

spec:
  volumes:
    - name: web-data
      mountPath: /data
      size: 1G
FieldTypeMeaning
namestringThe volume's name, validated the same way a jail name is (it becomes a ZFS dataset path component: {pool}/keel/volumes/{name}). Must be unique within this jail's own volumes list.
mountPathstringWhere the volume is mounted inside the jail's rootfs, e.g. /data.
sizestringA hard ZFS quota for the volume's dataset, same K/M/G-suffix grammar as spec.resources.memory (e.g. "1G"). What happens when an application fills it (ENOSPC surfaced directly to the jailed process) is left entirely to ZFS's own enforcement, the same way resources.cpu/memory already rely on rctl's.

kind: Service

A kind: Service spec describes N interchangeable replicas of a jail template, spread across nodes, self-healed, and load-balanced behind one stable virtual IP. Applying one produces deterministically-named jails <name>-0 through <name>-{N-1}; those replica names share the same flat namespace as plain kind: Jail jails, so a name already in use, by either kind, is rejected.

apiVersion: keel/v1
kind: Service
metadata:
  name: web
spec:
  replicas: 2
  port: 8080
  template:
    image: base/14.2-web
    command: ["/usr/local/bin/myapp"]
    network:
      vnet: true
      bridge: keel0
    resources:
      cpu: "1"
      memory: "256M"
    restartPolicy: Always
    volumes:
      - name: data
        mountPath: /var/db
        size: 5G
FieldTypeMeaning
spec.replicasintegerDesired replica count. 0 is valid (scaled to zero), never an error. May change on any re-apply; this is the only field that may.
spec.portintegerThe port the service's virtual IP listens on, and the port it forwards to on every replica (same port both sides, no separate "target port"). Must be non-zero. Immutable after first apply, the same way spec.template is: a re-apply that changes it is rejected with a 409, requiring delete and re-apply instead.
spec.templateobjectThe same fields kind: Jail's spec has, minus network.address: a replica's address is always auto-assigned from its node's pod_cidr, never given directly. Immutable after first apply.
spec.template.volumeslist of objectsSame shape as kind: Jail's volumes. Defaults to an empty list. Presence of any entries makes every replica of this service stateful: each gets its own volume (the template's volume name suffixed with the replica's own name, e.g. data becomes web-0-data), and is pinned to whichever node it was first placed on for the rest of its life, no automatic failover if that node goes permanently Dead. Either every replica is stateful or none are; there is no per-replica opt-out.

A service's virtual IP is allocated once, deterministically, from the cluster's --service-cidr pool on first creation, and never changes for the life of the service (a scale-only re-apply preserves it exactly). See the HTTP API reference for how to read a service's VIP back, and Architecture for how replica placement, self-healing, and the load-balancing proxy fit together.

kind: Ingress

A kind: Ingress spec terminates HTTPS for one real domain and proxies it to a kind: Service's virtual IP, with a certificate issued and renewed automatically via DNS-01 against Let's Encrypt. See the automatic HTTPS quickstart for a full worked example, from an empty node to a real browser reaching your site over HTTPS.

apiVersion: keel/v1
kind: Ingress
metadata:
  name: blog
spec:
  host: example.com
  backend:
    service: hugo-site
    port: 8080
  tls:
    email: admin@example.com
FieldTypeMeaning
spec.hoststringThe domain this Ingress serves, validated as a syntactically well-formed DNS name (must contain a dot; each label must be 1-63 alphanumeric-or-hyphen characters, starting and ending alphanumeric). This is also the server_name nginx uses to pick this host's certificate via SNI, so one Ingress serves exactly one hostname; there is no wildcard or multi-domain support yet.
spec.backend.servicestringThe name of an existing kind: Service to proxy to. Checked at apply time against the currently-known service VIP table; an unknown name is rejected with a 400, not silently accepted and left unroutable. An Ingress can only ever point at a Service, never a bare Jail, since a Service already has the stable virtual IP an Ingress needs (see kind: Service above).
spec.backend.portintegerThe port on the backend service's VIP to proxy to. Must be non-zero.
spec.tls.emailstringThe contact email registered with the ACME account used to request this certificate, and every other Ingress's certificate on this node (the ACME account is per-node, not per-Ingress). Validated as a syntactically well-formed email address.

Applying a second kind: Ingress re-uses the same singleton nginx jail: nginx's configuration is regenerated as the union of every currently-applied Ingress spec, one server block per host, each with its own independently-issued and independently-renewed certificate. Deleting an Ingress removes its server block on the next reconcile pass but never tears the shared nginx jail down, the same "never destroy a shared resource speculatively" precedent ensure_bridge_exists already sets for bridges. See the Architecture page for how certificate issuance, renewal, and the traffic path all fit together, and the HTTP API reference for the /ingress/<name> routes.