Crystal tool for FreeBSD provisioning, agentless over SSH, multi-account and multi-provider. Switches a bare-metal server on OVH, Scaleway or Dedibox into its Linux rescue mode, installs FreeBSD 15 via mfsBSD-in-QEMU, then applies declarative configuration from a YAML tree.
Current version: 0.1.41 (24 April 2026).
Repository: https://github.com/aloli-crystal/beryl
License: MIT (see section at the bottom of this page).
|
Note
|
beryl sits before deploy: it prepares the FreeBSD server (OS, users, system packages, SSH keys, sshd hardening), then deploy deploys Marten or Kemal applications on top. |
OVH, Scaleway and Online.net/Dedibox do not offer FreeBSD as a native image. The manual procedure — KVM-over-IP, custom ISO, hand-rolled rescue mode — is neither reproducible nor automatable. beryl automates the full cycle from the operator’s workstation:
-
beryl init <account>creates the~/.config/beryl/<account>/tree (ADR-014). -
beryl add-provider <account>/<provider>stores API credentials in~/.config/beryl/.env.yml(kept outside the repo). -
beryl add-domain <account>/<domain>declares the DNS zone, the default hosting provider, and user SSH keys. -
beryl rescue <host>triggers the provider API to reboot into Linux rescue, then waits for SSH to come back. -
beryl scan <host> --dns --apply --hostname=<short>connects to the rescue, detects disks (lsblk -b -d), lays down A/AAAA and PTR DNS records, and writes a ready-to-bootstrap<host>.yml. -
beryl wipe <host>clears leftover ZFS pools and partitions. -
beryl bootstrap <host>launches an mfsBSD VM inside the rescue (via QEMU) and installs FreeBSD 15 on the declared disks (ADR-012/013: post-install runs outside the chroot). -
beryl follow-install <host>tails thebsdinstall.logfrom the mfsBSD VM in real time. -
beryl apply <host>reconciles the effective config (packages, users, SSH keys, sudoers) with the running FreeBSD server — work in progress, currently limited to a bootstrap-only cycle.
Configuration lives in ~/.config/beryl/, organised by account, then by domain:
~/.config/beryl/
├── _default.yml # FreeBSD baseline (timezone, admin/deploy users, pkgs)
├── .env.yml # credentials per account × provider (0600)
├── initech/ # one account
│ ├── _account.yml # metadata (legal name, contact)
│ ├── initech.example.yml # one domain (DNS zone, SSH keys, default provider)
│ ├── initech.example/
│ │ ├── loulou.initech.example.yml # one host
│ │ └── mail.initech.example.yml
│ └── initech-fr.example.yml
└── other-account/
└── example.com.ymlThe merge is hierarchical (most specific wins):
_default.yml → <account>/<domain>.yml → <account>/<domain>/<host>.yml.
Every host-taking subcommand accepts a path-like syntax that mirrors the
tree: initech/loulou.initech.example, or initech/loulou if the domain is
unambiguous, or simply loulou if only one account is declared.
-
Agentless: nothing to install on the target. beryl drives everything over SSH (the
sshshard fully isolates beryl from~/.ssh/config). -
One API shard per provider —
aloli-crystal/api-ovh,aloli-crystal/api-scaleway,aloli-crystal/api-dedibox. Each shard is standalone, MIT-licensed, unit-tested. -
Heterogeneous IDs across providers: a textual
service_namefor OVH, a UUID v4 for Scaleway, an integer for Dedibox. Theinitech/<ID>form works on all three: beryl resolves the public IP via the provider API and carries on. -
Single Crystal binary:
shards build --releaseyields a staticbin/berylthat runs on any POSIX workstation without a runtime.
Three providers are supported as of April 2026, in alphabetical order.
Dedibox identifies servers by an integer (e.g. 186260). Credentials are
registered through beryl add-provider initech/dedibox (API key + secret key
obtained from https://console.online.net/).
The Dedibox rescue is a Debian 12 image prepared through the API. Critical
quirk: the rescue user is not root but sd-<id> (e.g. sd-186260). The
password is generated by the API on each prepare_rescue call, and the IAM
SSH key attached to the account is auto-injected by the platform into
/home/sd-<id>/.ssh/authorized_keys. beryl then orchestrates a promotion to
root so the rest of the flow (scan, bootstrap, apply) works the same way
as on OVH.
The beryl rescue <host> flow logs four numbered steps:
-
1/4 —
prepare_rescue(server_id, image=debian-12_amd64): returns thesd-<id>login and a temporary password. -
2/4 —
reboot(server_id, reason="beryl rescue"). The Dedibox API occasionally returnsfalseon this POST even when the hardware does reboot; beryl logs a warning and proceeds on the actual TCP signal. -
2b/4 — waits for sshd to drop (proof that the hardware has gone down).
-
3/4 — waits for
sd-<id>@hostto answer (rescue Debian ready). -
4/4 — promote: copies
authorized_keysinto/root/.ssh/and enablesPermitRootLogin yesviasudo -S(password fed on stdin, never placed on the command line).
Known limitation: the Dedibox/Online API does not expose reverse DNS (PTR).
beryl scan --dns happily lays down A and AAAA records through the DNS
provider declared on the domain (OVH in practice) and renames the server in
the console when possible, but the PTR still has to be set manually on
https://console.online.net/ (limitation documented in aloli-crystal/api-dedibox).
Concrete example, one command per block so each can be copy-pasted on its own:
-
Switch into Debian rescue through the Dedibox API (4 numbered steps).
beryl rescue initech/186260 --provider=dedibox --apply -
Disk scan, A/AAAA records, write
cookie.initech.example.yml.beryl scan initech/186260 --provider=dedibox --dns --apply --hostname=cookie -
Wipe leftover ZFS pools / partitions on the declared disks.
beryl wipe initech/cookie.initech.example --apply -
Install FreeBSD 15 via mfsBSD-in-QEMU.
beryl bootstrap initech/cookie.initech.example --apply
OVH identifies a dedicated server by its textual service_name, typically of
the form ns3156789.ip-51-83-6.eu. Credentials are obtained through
beryl add-provider initech/ovh: beryl guides the token creation at
https://eu.api.ovh.com/createToken/ then calls POST /auth/credential to
produce a consumer key with the exact set of required access rules.
The OVH rescue is rescue64-pro, a 64-bit Debian image. The API switch is
a two-stage dance:
-
dedicated_servers.prepare_rescue(service_name, ssh_key_name)sets the boot order to rescue and returns an OVH task. -
beryl polls the task until it reaches a terminal state (
init→todo→doing→done). A livelog_stepcounter shows time spent in each state, so the operator immediately sees if a transition hangs.
Once the task is done, beryl waits for SSH to answer on root@host. The
SSH key injected into the rescue must be declared in the OVH panel; its label
(ssh_key_name) is then referenced by ovh.ssh_key_name in the domain YAML.
If the OVH account contains only one key, beryl auto-selects it and logs
the fact.
Concrete example, one command per block so each can be copy-pasted on its own:
-
Switch into rescue64-pro through the OVH API, poll the task.
beryl rescue initech/ns3156789.ip-51-83-6.eu --domain=initech.example --apply -
Disk scan, A/AAAA + PTR records (IPv4/IPv6), rename the OVH panel.
beryl scan initech/ns3156789.ip-51-83-6.eu --domain=initech.example --dns --apply --hostname=cookie -
Wipe leftover ZFS pools / partitions.
beryl wipe initech/cookie.initech.example --apply -
Install FreeBSD 15.
beryl bootstrap initech/cookie.initech.example --apply -
In a second terminal, tail
bsdinstall.logfrom the mfsBSD VM.beryl follow-install initech/cookie.initech.example
With --dns, scan lays down A/AAAA records in the DNS zone, PTR records
through the OVH API (IPv4 + IPv6), and renames the server in the panel (the
service_name stays, but the display name becomes the desired short name).
Scaleway Elastic Metal identifies servers by UUID v4 (e.g.
9783705f-f86f-4bf7-88ad-4c92b920e9ba), scoped by geographic zone
(fr-par-1, fr-par-2, pl-waw-3…). Credentials are registered through
beryl add-provider initech/scaleway — secret key + project ID, with the
required IAM permissions to tick manually in the console (the Scaleway API
has no auto-gen path).
The Scaleway rescue is a live Ubuntu image. Three critical specifics, source: https://www.scaleway.com/en/docs/bare-metal/elastic-metal/how-to/use-rescue-mode/
-
The rescue user is
rescue, notroot. It is a passwordless sudoer in the official rescue image. -
SSH keys injected into the rescue are ONLY those from
install.ssh_key_ids, set at the server’s initial install. Adding a key to the Scaleway project after the server is created does not propagate to the rescue: the key will be missing from/home/rescue/.ssh/authorized_keys. -
To resync keys you must redo an install (destructive operation). beryl exposes this through
beryl scaleway-reinstall <host>, which callsPOST /servers/{id}/installwith the up-to-date project keys. Only safe on a brand-new server or one whose OS is expendable.
To keep the flow uniform with OVH and Dedibox, beryl rescue automatically
promotes rescue → root once SSH is back: copies
/home/rescue/.ssh/authorized_keys into /root/.ssh/, sets
PermitRootLogin yes in /etc/ssh/sshd_config.d/beryl.conf, reloads sshd.
The subsequent commands (scan, bootstrap, apply) then connect as
root@host like anywhere else.
Before the Rescue reboot, beryl runs a pre-check (step 1/3 in the logs):
it reads install.ssh_key_ids from the server, fetches each public key by
ID, and compares the base64 payload with the local host.identity_file.pub.
If the local key is missing from that list, beryl refuses to proceed and
explicitly points to beryl scaleway-reinstall.
Concrete example (chouquette, zone pl-waw-3), one command per block so each
can be copy-pasted on its own:
-
Pre-check
install.ssh_key_ids, then switch into Ubuntu rescue. beryl resolves the IP and the zone from the UUID via the API, and promotesrescue→rootonce SSH is back.beryl rescue initech/9783705f-f86f-4bf7-88ad-4c92b920e9ba --provider=scaleway --apply -
Run only if step 1 bailed out on the pre-check: resync SSH keys through the official API. DESTRUCTIVE: reinstalls the OS on disk. Safe only on a brand-new server.
beryl scaleway-reinstall initech/9783705f-f86f-4bf7-88ad-4c92b920e9ba -
Disk scan, A/AAAA, IPv4 reverse DNS, rename the Scaleway console.
beryl scan initech/9783705f-f86f-4bf7-88ad-4c92b920e9ba --provider=scaleway --dns --apply --hostname=chouquette -
Wipe leftover ZFS pools / partitions.
beryl wipe initech/chouquette.initech.example --apply -
Install FreeBSD 15.
beryl bootstrap initech/chouquette.initech.example --apply
-
Clone the repository.
git clone https://github.com/aloli-crystal/beryl.git
-
Fetch dependencies (Initech shards + stdlib).
cd beryl && shards install
-
Build the release binary.
shards build --release
The beryl binary lands in ./bin/beryl. No runtime dependency beyond
libssl / libcrypto, present by default on macOS and Linux.
Every command is a dry run by default (it shows what it would do);
--apply actually runs it.
-
Create the
~/.config/beryl/initech/tree (interactive prompts).beryl init initech --apply
-
Store OVH credentials in
~/.config/beryl/.env.yml.beryl add-provider initech/ovh --apply
-
Declare DNS zone, default provider, SSH keys.
beryl add-domain initech/initech.example --apply
-
Switch the server into rescue.
beryl rescue initech/cookie.initech.example --apply
-
Detect disks, set up DNS, write the host YAML.
beryl scan initech/cookie.initech.example --dns --apply --hostname=cookie
-
Wipe leftover pools / partitions.
beryl wipe initech/cookie.initech.example --apply
-
Install FreeBSD 15.
beryl bootstrap initech/cookie.initech.example --apply
The full CLI reference (subcommands, shorts, flags) lives in docs/CLI-REFERENCE.adoc. Detailed architecture and roadmap are in ARCHITECTURE.adoc and docs/adr/.
beryl pg-upgrade <host> upgrades the PostgreSQL server of a FreeBSD host to
a new major version (e.g. 18 → 19) with pg_upgrade, following the procedure
from the FreeBSD ports UPDATING file. Dry-run by default: beryl detects the
versions, the data dirs and the cluster options, checks the preconditions and
prints the plan.
beryl pg-upgrade acme/db1.example.net --to 19 --stop-services myapp
beryl pg-upgrade acme/db1.example.net --to 19 --stop-services myapp --applyWhat beryl checks before touching anything:
-
the target
postgresqlNN-serverpackage is in the repository (--to, otherwise the most recent version in the repository); -
installed packages that depend on PostgreSQL (drivers, extensions) exist in the repository rebuilt for the new version. Otherwise beryl refuses: rebuild the repository first with
DEFAULT_VERSIONS+= pgsql=NN; -
disk space (data dir copy +
pg_dumpallbackup).
The new cluster gets the encoding, locale and data checksums state of the old
one. pg_upgrade --check runs first. If anything fails before the new server
starts, beryl automatically rolls back to the old version. --link is faster
but rules out any rollback once the new server has started.
The old data dir is kept. Once the new version is validated:
beryl pg-upgrade acme/db1.example.net --cleanup --applyberyl scan asks for each pool’s RAID level as a number. Numbers 5, 6 and 7
are a beryl convention, by analogy with hardware RAID; ZFS itself says
raidz, raidz2 and raidz3. Only the levels achievable with the selected
disks are offered.
| Choice | ZFS mode | In plain words | Min. disks | Failures tolerated | Usable capacity (N disks) |
|---|---|---|---|---|---|
|
stripe |
Data spread over all disks, no copy. Fastest and most space, but one lost disk = everything lost. |
1 |
0 |
N |
|
mirror |
Mirror: every disk holds the same copy. |
2 |
N − 1 |
1 disk |
|
raidz |
Single parity (RAID 5 equivalent). |
3 |
1 |
N − 1 |
|
raidz2 |
Double parity (RAID 6 equivalent). |
4 |
2 |
N − 2 |
|
raidz3 |
Triple parity (ZFS-specific). |
5 |
3 |
N − 3 |
|
mirror_stripe |
Mirrored pairs, data spread over the pairs (RAID 10). |
4, even number |
1 per pair |
N / 2 |
Rules of thumb:
-
2 disks:
1(mirror) is the only redundant choice. -
3 disks:
5(raidz). -
4 disks or more:
6(raidz2) for safety and space — it survives two simultaneous failures, valuable with large disks whose rebuild takes hours;10for performance (databases), at the cost of half the space. -
0only suits disposable data (cache) or when relying entirely on backups. For a multi-disk zroot in0,beryl scanwarns and asks for confirmation.
On a builder managed by the poudriere-build primitive, beryl build-status
shows the build state, read-only: running (since when, phase, packages being
built), succeeded, failed or never built; counters; FAILED packages first
with the path of their poudriere log; built packages, longest first; last
successful build and overlay commits. Output is in French.
beryl build-status pkg.example.net
beryl build-status pkg.example.net --major 15 --follow
beryl build-status pkg.example.net --all --sort name
beryl build-status pkg.example.net --json--follow refreshes every 30 s until the build ends, then exits 0 (success)
or 1 (failure). Exit codes: 0 success / running, 1 last build failed,
2 SSH / usage.
MIT © ALOLI sas. See LICENSE.