# Skiff A minimal PaaS for FreeBSD + Nginx apps, in any language with a Makefile. `git push` triggers a build, an optional app-defined release phase, a ZFS snapshot, a cutover, and a health check - with automatic rollback to the pre-deploy snapshot if the new release fails its health check. One binary, no daemon, no separate metadata database - jail.conf, ZFS datasets, and a couple of flat files per app are the only state. Skiff has no opinion on your language or runtime - the only thing it requires of an app is a `Makefile` that produces an executable named `app`, and that `app` accepts TCP traffic on an assigned port. See [What an app needs to provide](#what-an-app-needs-to-provide). ## Requirements - FreeBSD 15, ZFS root - `nginx` and `git` - neither ships in base; install with `pkg install nginx git-lite` - `jail_enable="YES"` in `/etc/rc.conf` (`sysrc jail_enable=YES`) - `service jail start`/`restart` refuse to run without it - Whatever toolchain your apps' Makefiles invoke to build - `go`, `python`, `rustc`, `node`, etc. - installed on the **host** (builds run outside the jail; see [One-time host setup](#one-time-host-setup)). `make` itself is already in FreeBSD base (bmake), so it's the one build tool Skiff never asks you to install. ## One-time host setup ### ZFS layout ``` # zfs create zroot/skiff # zfs create -o mountpoint=/skiff/base zroot/skiff/base # bsdinstall jail /skiff/base # zfs snapshot zroot/skiff/base@ready # zfs create -o mountpoint=/skiff/apps zroot/skiff/apps ``` `ready` is the snapshot every app's dataset gets cloned from. Re-snapshot under a new tag after patching the base (e.g. `security-2026-08`) and pass `--base-snapshot` to `new-app` to pick it up. `zroot/skiff/apps` must exist as its own dataset, not a plain directory - `new-app` clones straight into it (`zroot/skiff/apps/`), and `zfs clone` requires the parent dataset to already exist. ### Repos directory ``` # mkdir -p /skiff/repos ``` ### Nginx Add once to `/usr/local/etc/nginx/nginx.conf`, inside the `http` block: ``` include /usr/local/etc/nginx/skiff.d/*.conf; ``` ``` # mkdir -p /usr/local/etc/nginx/skiff.d ``` Skiff only ever writes files under `skiff.d/` and reloads - it never touches `nginx.conf` itself. ### Install skiff ``` $ GOOS=freebsd GOARCH=amd64 go build -o skiff ./cmd/skiff # install -m 755 skiff /usr/local/sbin/skiff ``` ## Deploying an app ### First deploy ``` # skiff new-app --name myapp --ip 10.0.0.5 --port 8080 --server-name myapp.example.com ``` Clones the base dataset, writes `/etc/jail.conf.d/myapp.conf` and the jail's own rc.d service, creates the bare repo at `/skiff/repos/myapp.git` with its `post-receive` hook installed, and starts the (empty) jail. Prints the remote URL to add locally. ### Every deploy after ``` $ git remote add skiff root@host:/skiff/repos/myapp.git $ git push skiff main ``` The push blocks until the deploy finishes - success or failure prints straight to your terminal, no dashboard to check. ### Rollback ``` # skiff rollback --app myapp --list # skiff rollback --app myapp --to pre-a1b2c3d ``` Rolls the app's entire ZFS dataset back to that snapshot (code and any release-phase side effects together) and restarts the jail. This uses `zfs rollback -r`, which **destroys every snapshot newer than the target** - ZFS can't roll back past one otherwise. Rolling back to an old snapshot permanently discards the ability to return to anything deployed in between; there's no separate "undo the rollback." ## What an app needs to provide A `Makefile` in the repo root, and nothing else Skiff cares about - no required language, framework, or file layout beyond this contract: - Running bare `make` (Skiff runs `make -B`, so it never trusts a stale build artifact left in the checkout; every deploy is a fresh worktree anyway) must leave an executable file named `app` in the repo root. What produces it is entirely up to you - `go build -o app .`, a `cp target/release/myapp app` after `cargo build --release`, a shell script with a `#!/usr/bin/env python3` shebang that `chmod +x`'d itself into place, whatever. Skiff execs this file directly (via `daemon(8)`), and the kernel doesn't care whether it's a native binary or a script - only that it's marked executable and has a valid interpreter line if it's not. - That executable listens on the port given to `new-app` (bind the wildcard address, not `127.0.0.1`), and answers the configured health path with a non-3xx status once ready. - Optionally, a `release` target in the same Makefile - run once inside the jail *before* cutover (against the new release's own checkout, not whatever `current` still points at), gating the deploy on its exit code. Skiff doesn't interpret this target; it's entirely the app's own convention (`sqlite3` migrations, cache warming, whatever). Detected by asking `make` itself via `make -n release`, not by parsing the Makefile - it can be defined however you like (directly, via an include, a pattern rule). ```makefile .PHONY: all release all: app app: main.go go.mod go build -o app . release: sqlite3 /app/data.db < migrations/0004_add_x.sql ``` See [USER_GUIDE.md](USER_GUIDE.md) for a full walkthrough with a real example app. Runtime dependencies (a Python interpreter, the Node runtime, etc.) must be present in the jail's base image if your `app` isn't a self-contained static binary - that's a base-jail provisioning concern, not something the Makefile handles. ## Known gaps - No IP allocation - `--ip` is operator-chosen. Fine for a handful of apps on one host; would want a small pool allocator beyond that. - Old `releases/` directories are never pruned. The ZFS snapshot is the real rollback mechanism; the release dirs are just there for the instant symlink flip and are safe to clean up by hand. - Single host only. Scaling to a second host is a `zfs send`/`receive` of an app's dataset plus the same jail.conf/nginx steps - not yet automated here.