summaryrefslogtreecommitdiff
path: root/USER_GUIDE.md
blob: 5ff93f15a7f7b60671b19b1bed7fc79dfbcdafa2 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
# Skiff User Guide

This guide covers Skiff from two angles: **Developers**, who just want to
`git push` their app, and **System Administrators**, who provision and run
the FreeBSD host Skiff deploys onto. If you only need the quick reference,
see [README.md](README.md); this document goes deeper and includes
operational details discovered by actually standing up a host end-to-end.

## Contents

- [How Skiff works, in one picture](#how-skiff-works-in-one-picture)
- [Part 1: For Developers](#part-1-for-developers)
- [Part 2: For System Administrators](#part-2-for-system-administrators)
- [Troubleshooting](#troubleshooting)
- [Command reference](#command-reference)

## How Skiff works, in one picture

```
you                          skiff host
 |                            |
 | git push skiff main        |
 |--------------------------->| post-receive hook -> `skiff deploy`
 |                            |   1. git worktree add (checkout the sha)
 |                            |   2. make -B (must produce ./app)
 |                            |   3. zfs snapshot (pre-<sha>)
 |                            |   4. optional Makefile `release` target
 |                            |   5. flip `current` symlink, restart jail
 |                            |   6. poll health path for up to 10s
 |                            |        healthy -> update nginx, done
 |                            |        unhealthy -> zfs rollback -r, restart
 | <--------------------------|
 | push succeeds/fails        |
```

Every app lives in its own FreeBSD jail, cloned from a shared ZFS base
image. There's no scheduler, no dashboard, no separate metadata database -
`jail.conf.d/<app>.conf`, the ZFS dataset, and a `meta.json` file are the
only state, and the entire control plane is the one `skiff` binary.

---

## Part 1: For Developers

### What your app needs to provide

Skiff has no opinion on your language - the entire build/run contract is a
`Makefile` in your repo root:

- Running bare `make` (Skiff runs `make -B`, so a stale artifact left
  over from a previous local run is never trusted - not that one would
  exist anyway, since every deploy is a brand-new checkout) must leave an
  executable file named `app` in the repo root. Skiff execs this file
  directly via `daemon(8)` once it's deployed - the kernel handles
  interpreter shebang lines (`#!/usr/bin/env python3`, etc.) exactly like
  a native binary, so *any* language works as long as something ends up
  executable at that one fixed path. A Go example:

  ```makefile
  .PHONY: all
  all: app

  app: main.go go.mod
  	go build -o app .
  ```

  A Python example, to show it's not just "compiled languages with an
  extra step":

  ```makefile
  .PHONY: all
  all: app

  app: main.py requirements.txt
  	python3 -m venv .venv
  	.venv/bin/pip install -q -r requirements.txt
  	printf '#!/bin/sh\nexec %s/.venv/bin/python3 %s/main.py\n' "$$(pwd)" "$$(pwd)" > app
  	chmod +x app
  ```

- That executable must listen on the port your sysadmin assigned when
  they ran `new-app` (default `8080`), and answer the configured health
  path (default `/healthz`) with a non-3xx status once it's ready to
  serve traffic. Bind to `:<port>` (all interfaces), not
  `127.0.0.1:<port>` - each jail has exactly one IP and binding to it
  explicitly is unnecessary; the wildcard bind resolves correctly inside
  the jail's own address space.

- Optionally, a `release` target in the same Makefile, run once inside
  the jail *before* cutover - against the **new** release's own checkout
  (Skiff invokes it as `make -C <new-release-dir> release`, so relative
  paths work), not whatever `current` still points at:

  ```makefile
  release:
  	sqlite3 /app/data.db < migrations/0004_add_x.sql
  ```

  Skiff never interprets this target - it's entirely your own
  convention. If it exits non-zero, the deploy aborts *before* touching
  the live release, so a bad migration never takes your app down. Skiff
  detects whether you've declared one by asking `make` itself
  (`make -n release`), not by reading your Makefile's text - it doesn't
  care how the target is defined, only whether bmake thinks it exists.

- Persistent state: `/app/data.db` and `/app/env` (jail-relative paths,
  siblings of `/app/current`) are meant to be your durable state,
  untouched by any single release's checkout/build. In practice today
  this is a *convention*, not something Skiff actively manages for you -
  there's no automatic env-var injection or database provisioning. If you
  want a SQLite file that survives every deploy, just open it at
  `/app/data.db` yourself and it'll sit outside the `releases/<sha>`
  directories that get replaced on every push.

### Deploying

Once your sysadmin has run `new-app` for you, they'll give you a remote
URL:

```
$ git remote add skiff root@host:/skiff/repos/myapp.git
$ git push skiff main
```

**Only pushes to `main` trigger a deploy.** Push to any other branch and
the hook no-ops (prints a message, exits 0) - handy for keeping a
`skiff` remote around without accidentally deploying WIP branches.

The push blocks until the deploy finishes - build, snapshot, cutover, and
health check all stream straight back to your terminal:

```
remote: ==> checking out 1a36776...
remote: ==> building
remote: ==> snapshotting current state
remote: ==> no release target declared, skipping
remote: ==> cutting over
remote: Stopping jails: myapp.
remote: Starting jails: myapp.
remote: ==> healthy, updating routing
remote: ==> deployed myapp at 1a36776...
```

There's no dashboard - if it printed `healthy`, it's live; check the
terminal output, not a web UI.

### If the health check fails

Skiff automatically rolls the app's entire ZFS dataset back to right
before your deploy and restarts the jail on the old release:

```
remote: ==> health check failed (never became healthy at http://10.0.0.5:8080/healthz: ...), rolling back to pre-1a36776...
remote: Stopping jails: myapp.
remote: Starting jails: myapp.
remote: error: deploy failed health check and was rolled back: ...
```

The old version is back up by the time you see this error - you did not
just take the app down. Common causes, roughly in order of likelihood:

1. Your app doesn't actually bind to the assigned port within 10 seconds
   (that's the whole health-check budget - retried every 500ms).
2. It's listening on `127.0.0.1` instead of the wildcard address, or on
   the wrong port.
3. The health path returns a 3xx/4xx/5xx instead of 2xx.
4. It panics/crashes immediately on start - check with your sysadmin,
   who can `jexec <app> /app/current/app` to see stderr directly, or
   inspect syslog inside the jail.

**One git subtlety worth knowing:** even when the deploy fails and rolls
back, the git ref on the server *still advances* to your new commit -
git itself doesn't reject the push, only the app-level deploy failed
after the fact. So if you fix nothing and just run `git push` again,
you'll see `Everything up-to-date` and nothing will happen, because
there's no new commit to push. Either make a new commit (even
`git commit --allow-empty -m retry` while you investigate) or ask your
sysadmin to re-run the deploy directly on the host:
`skiff deploy --app myapp --new <sha> --ref refs/heads/main`.

### Rolling back a bad release yourself

If you have shell access to the host (or your sysadmin does it for you):

```
# skiff rollback --app myapp --list
# skiff rollback --app myapp --to pre-a1b2c3d
```

Read the sysadmin section's [rollback notes](#rollback) before you do
this in a hurry - it's more destructive than "undo."

---

## Part 2: For System Administrators

### Requirements

- FreeBSD 15, ZFS root
- `nginx` and `git` - **neither ships in FreeBSD base.** Install them
  with:
  ```
  # pkg install nginx git-lite
  ```
- Whatever your apps' Makefiles actually invoke to build - `go`,
  `python3`, `rustc`, `node`, etc. - installed on the **host** (builds
  run outside the jail, via `make -B` in the checked-out worktree). Skiff
  itself doesn't care which; `make` is already in FreeBSD base (bmake),
  so it's never something you need to install separately.
- `jail_enable="YES"` in `/etc/rc.conf`:
  ```
  # sysrc jail_enable=YES
  ```
  Without this, `service jail start`/`restart` refuse to run at all, and
  every deploy will fail at the cutover step with a jail that never
  actually starts.

### One-time host setup

#### 1. 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
```

`zroot/skiff/apps` must exist as its own dataset, **not a plain
directory** - `new-app` clones straight into it
(`zroot/skiff/apps/<name>`), and `zfs clone` requires its parent dataset
to already exist. A plain `mkdir /skiff/apps` will let `new-app` fail
with `cannot create '...': parent does not exist`.

`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.

**If `bsdinstall jail` hangs at 100% CPU with no output:** it's spawned
an interactive `dialog` (distribution-set vs. packages, mirror
selection) and has no terminal to answer it - common when driving this
over a non-interactive SSH session. Kill it (`pkill -9 bsddialog`) and
re-run fully non-interactive instead:

```
# export nonInteractive=YES
# export BSDINSTALL_DISTSITE=https://download.freebsd.org/releases/amd64/amd64/15.0-RELEASE
# bsdinstall jail /skiff/base
```

(match the release number to your host's own `uname -r`; check
`https://download.freebsd.org/releases/amd64/amd64/<version>/MANIFEST`
exists before relying on it.) You can watch real progress with
`zfs list zroot/skiff/base -o used` even while `dialog`'s own progress
bar is garbled without a real tty.

#### 2. Repos directory

```
# mkdir -p /skiff/repos
```

#### 3. 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
# sysrc nginx_enable=YES
# service nginx start
```

Skiff only ever writes files under `skiff.d/` and reloads - it never
touches `nginx.conf` itself.

#### 4. Install skiff

Cross-compile from any machine with Go, then copy the binary over:

```
$ GOOS=freebsd GOARCH=amd64 go build -o skiff ./cmd/skiff
$ scp skiff root@host:/tmp/skiff
# install -m 755 /tmp/skiff /usr/local/sbin/skiff
```

### Provisioning a new app

```
# skiff new-app --name myapp --ip 10.0.0.5 --port 8080 --server-name myapp.example.com
```

This clones the base dataset, writes `/etc/jail.conf.d/myapp.conf` and
the jail's own `/usr/local/etc/rc.d/myapp` script (installed *inside*
the jail's own filesystem, not the host's), creates the bare repo at
`/skiff/repos/myapp.git` with its `post-receive` hook, and starts the
(empty) jail.

**Critical, undocumented-by-default step: alias the IP onto the host.**
Skiff's jails are classic (non-VNET) jails - `--ip` is an address the
jail binds to, but FreeBSD won't route to it unless that address already
exists on a host network interface. `new-app` does **not** do this for
you:

```
# ifconfig <your-interface> alias 10.0.0.5/24
```

Without this, the jail starts fine but is completely unreachable - every
deploy for that app will fail its health check and roll back forever,
even though the app itself is working correctly inside the jail. This
was the single most confusing failure mode encountered testing Skiff:
the deploy pipeline, build, and app were all fine, and the jail was
simply not reachable. If you're provisioning many apps, plan a real IP
range up front and script the alias step - see [Known
gaps](#known-gaps) below.

Make the alias persistent across reboots via `/etc/rc.conf`
(`ifconfig_<if>_alias0="inet 10.0.0.5/24"`) rather than a one-off
`ifconfig` command, or it won't survive a host restart.

### 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." Treat
`--to` as a one-way door, not a slider you can move back and forth.

Because the bare git repo lives on a *separate* dataset from the app
(`/skiff/repos` vs. `/skiff/apps/<name>`), a rollback can delete a
release directory whose git worktree is still registered in the repo.
Current `skiff` versions self-heal this automatically (checkout runs
`git worktree prune` first); if you're running an older binary and see
`missing but already registered worktree`, run
`git -C /skiff/repos/<app>.git worktree prune` by hand and retry.

### Inspecting a running app

```
# jls                                  # list running jails
# jexec myapp ps aux                   # processes inside the jail
# jexec myapp sockstat -4 -l           # what's actually listening
# jexec myapp service myapp status     # the app's own rc.d status
# service jail restart myapp           # what skiff itself calls on deploy
```

### Known gaps

Carried over from the project's own README, plus what testing surfaced:

- **No IP allocation.** `--ip` is operator-chosen and must be manually
  aliased onto a host interface (see above) - fine for a handful of
  apps on one host, but you'll want a small pool allocator and an
  automated alias step beyond that.
- **Old `releases/<sha>` directories are never pruned.** The ZFS
  snapshot is the real rollback mechanism; release directories are just
  there for the instant symlink flip and are safe to clean up by hand
  once you're confident you won't roll back to them.
- **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 automated.
- **`skiff rollback -r` is destructive** (see above) - there is
  currently no confirmation prompt or dry-run.

---

## Troubleshooting

| Symptom | Likely cause | Fix |
|---|---|---|
| `zfs clone ...: parent does not exist` on `new-app` | `zroot/skiff/apps` was created as a directory, not a dataset | `zfs create -o mountpoint=/skiff/apps zroot/skiff/apps` |
| `Cannot 'start' jail. Set jail_enable to YES...` | `jail_enable` unset | `sysrc jail_enable=YES` |
| Deploy always health-check-fails with `connection refused`, even though the app builds fine | Jail's IP was never aliased onto a host interface | `ifconfig <if> alias <ip>/<mask>` (see [Provisioning](#provisioning-a-new-app)) |
| `git worktree add ...: missing but already registered worktree` on redeploy | Stale worktree entry after a rollback deleted a release dir (older skiff binaries only) | `git -C /skiff/repos/<app>.git worktree prune` |
| `bsdinstall jail` spins at 100% CPU forever | Interactive `dialog` waiting on a terminal that isn't there | Kill it, re-run with `nonInteractive=YES` and `BSDINSTALL_DISTSITE` set (see setup section) |
| `git push` says `Everything up-to-date` but you wanted to retry a failed deploy | The git ref already advanced despite the deploy failing - nothing new to push | New commit (`git commit --allow-empty`), or on the host: `skiff deploy --app <app> --new <sha> --ref refs/heads/main` |
| `build: \`make\` did not produce ./app` | Your Makefile's default target ran (or was skipped as "up to date") without ultimately leaving a file named `app` in the repo root | Run `make -B` locally in a clean checkout and confirm `./app` exists after; check the target actually named `app` (or a dependency of the default target) writes there |
| Health check fails and you're not sure why | - | `jexec <app> sockstat -4 -l` to check for a listener; `jexec <app> ps aux` to see if the process is even running; try `jexec <app> /app/current/app` directly to see startup errors that daemon(8) would otherwise swallow |

---

## Command reference

```
skiff new-app --name <app> --ip <ip> [--port 8080] [--health-path /healthz]
              [--server-name <name>] [--base-snapshot ready]

skiff deploy --app <app> --new <sha> [--ref refs/heads/main] [--old <sha>]
             (normally invoked by the post-receive hook, not by hand)

skiff rollback --app <app> --list
skiff rollback --app <app> --to <snapshot-tag>
```