summaryrefslogtreecommitdiff
path: root/README.md
blob: 632fbb0b380552c04bbc8656795ee40f9cc04395 (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
# 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/<name>`), 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/<sha>` 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.