JailSpec reference
The YAML schemas for a single Keel jail (kind: Jail), a self-healing, load-balanced replica set (kind: Service), and automatic HTTPS for a real domain (kind: Ingress), all defined in keel-spec.
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
| Field | Type | Meaning |
|---|---|---|
apiVersion | string | Schema version, currently always keel/v1. |
kind | string | Resource kind, currently always Jail. |
metadata.name | string | The 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.image | string | Which 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.command | list of strings | The command (and its arguments) to run inside the jail. |
spec.network.vnet | boolean | Whether 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.bridge | string | The bridge(4) interface the jail's epair(4) pair attaches to. |
spec.network.address | string (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.cpu | string | CPU limit, parsed into an rctl(8) pcpu percentage (e.g. "2" means 2 cores' worth). |
spec.resources.memory | string | Memory limit, parsed into bytes for rctl(8) (e.g. "512M"). |
spec.restartPolicy | Always | OnFailure | Never | Whether the reconciler restarts the jail's command after it exits, and under what conditions. |
spec.volumes | list of objects | Named 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
| Field | Type | Meaning |
|---|---|---|
name | string | The 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. |
mountPath | string | Where the volume is mounted inside the jail's rootfs, e.g. /data. |
size | string | A 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
| Field | Type | Meaning |
|---|---|---|
spec.replicas | integer | Desired 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.port | integer | The 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.template | object | The 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.volumes | list of objects | Same 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
| Field | Type | Meaning |
|---|---|---|
spec.host | string | The 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.service | string | The 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.port | integer | The port on the backend service's VIP to proxy to. Must be non-zero. |
spec.tls.email | string | The 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.
Keel