Menu
0.1.0-betaProof of conceptBSD-2-Clause

A cluster-first container engine for FreeBSD.

SatL runs OCI containers as FreeBSD jails, with the orchestration inside the daemon rather than beside it — an embedded Raft store, a scheduler, VXLAN overlays, an ingress routing mesh and mTLS everywhere. It speaks the Docker Engine API, so the tooling you already have works against it unchanged.

A single node is a cluster of one, and every container is a task of a service. There is nothing to init.

The SatL logo: a shipping container in orbit, with solar panels, a communications dish and three thruster plumes over the curve of a planet.
    
      satl service create --name web --replicas 3 -p 8080:80 freebsd-nginx:latestsatl stack deploy -c compose.yaml shopsatl service update --limit-memory 512m web
    
  

Three replicas behind a published port, a stack from a compose file, and a memory limit rewritten on the live jails — no restart, same task IDs.

Results

What it does on a three-node cluster

Measured on FreeBSD 15.1, with the conditions for each figure stated underneath.

0of 6612

requests lost during a rolling update

A six-replica service updated across three nodes while traffic ran through the ingress routing mesh. 6612 requests sampled, none dropped.

9.97s

from failed probe to out of the traffic pool

Health probe failure to the task address leaving the pf redirect pool. Two runs: 9.967 s and 9.971 s. Docker’s defaults take roughly ninety.

6s

for a lost manager to rejoin

End to end on a cluster that still had quorum, with no backup involved. The cluster kept committing writes throughout.

7s

warm rebuild, against 51 s cold

The same image rebuilt through the content-addressed incremental build cache with nothing changed.

1.4MB

smallest FROM scratch image

A statically linked C binary, assembled in a multi-stage build from a FreeBSD toolchain stage.

The full set, with the runs that produced them, is in the project’s roadmap and across the status page. Measured on a small cluster — your hardware and workload will differ.

Quick install

Two commands, and six things to do first

The order matters. The daemon will start with several of these missing and degrade quietly, which is why host preparation comes before the package rather than after it.

Install the package

A self-contained FreeBSD package — it needs no repository of its own, and it installs four files. Two steps rather than one so you can see the file land, check it, and copy it to the other nodes of a cluster instead of downloading it three times.

    
      fetch https://satl.cc/download/satl-freebsd.pkgpkg add ./satl-freebsd.pkg
    
  

One package, every node. A cluster wants the same build everywhere. Fetch once,scp the file around, pkg add on each machine — mixing versions across nodes is not a configuration SatL is tested in.

Or build it from source

This is also how you get an unreleased fix, and it is the only path that needs a Rust toolchain — Rust 1.96, edition 2024.

    
      git clone https://github.com/fredericalix/satl satlcd satlmake install
    
  

Check it came up

    
      satl versionsatl node lsdocker -H unix:///var/run/satl.sock version
    
  

The third line only applies if you have the docker CLI installed. It is the fastest proof that the Engine API is answering.

Prepare the host first

In this order — each one depends on the one before it. Every step here, with the symptom you get for skipping it, is on the requirements page; the package’s post-install message recalls them too.

  1. Create the ZFS dataset

        
          zfs create -o mountpoint=/var/db/satl zroot/satl
        
      

    ZFS is mandatory, not one storage driver among several — a layer *is* a dataset. satld refuses to start without this. Substitute your pool name and set zfs_root in the config if it is not zroot.

  2. Turn on IP forwarding

        
          sysrc gateway_enable=YESsysctl net.inet.ip.forwarding=1
        
      

    Container traffic is routed between the bridge and the egress interface. Skip it and you get the most misleading symptom in SatL: published ports answer, and containers cannot reach anything.

  3. Declare the pf anchors

        
          nat-anchor "satl/*"rdr-anchor "satl/*"anchor     "satl/*"pass all
        
      

    In /etc/pf.conf, translation anchors before any filter rule. SatL owns the satl/* anchors and never writes a rule outside them. An anchor not declared in pf.conf is never evaluated — satld will load rules into it, report success, and change nothing about how packets move.

  4. Set the boot tunables

        
          kern.racct.enable=1if_vxlan_load="YES"
        
      

    In /boot/loader.conf, then reboot when convenient. The first enables enforced --memory and --cpus through rctl; the second is only needed for overlay networks.

  5. Write satld.toml — do not skip this

        
          pf_mode = "enforce"
        
      

    The package installs satld.toml.sample, not satld.toml. A missing config is legal and the daemon runs on defaults — and the default pf_mode is check, which generates the pf rules, syntax-checks them, and never loads one. A published port is then allocated, recorded and shown by satl ps exactly as if it worked, with no redirect behind it and nothing logged as an error. This catches essentially every first install.

  6. Enable and start the daemon

        
          sysrc satld_enable=YESservice satld start
        
      

    Then read the startup lines: grep -a satld /var/log/messages | tail -40. The -a is not optional.

What is built

Eight things worth knowing

Not a roadmap. Every item below is in the release, and every one has been run on FreeBSD.

  • Containers are jails

    One VNET jail per task, with its own network stack, driven through the ocijail runtime. SatL implements no container runtime of its own and never will — it pulls the image, applies the layers, generates an OCI runtime spec, and hands it over.

  • ZFS-native image store

    A layer is a dataset. Applying a layer is a snapshot and a clone; a container’s writable layer is a clone. There is no graph driver to choose and nothing to tune, because there is no alternative to tune it against.

  • The surface is Docker’s

    satld serves the Docker Engine REST API v1.43 on /var/run/satl.sock, negotiable down to 1.24, and the satl CLI mirrors the verbs you already know. docker -H unix:///var/run/satl.sock version works, and so does your existing tooling.

  • Nothing to initialise

    A fresh daemon mints a node identity, a cluster identity and a root CA, initialises a one-member Raft cluster, and starts scheduling. There is no swarm init, no standalone mode, and no etcd beside it — one daemon is the whole control plane.

  • VXLAN overlays, DNS discovery

    One VNI per network, unicast, with a Raft-distributed forwarding table and learning turned off. A service name resolves to its running tasks, shuffled per query, through a DNS responder on every node.

  • An ingress mesh in pf

    Ports are allocated cluster-wide and every manager answers every published port, relaying to a live task with return-path SNAT. Opt into PROXY protocol v2 per service when a task needs the real client address.

  • mTLS everywhere, by default

    An ECDSA P-256 cluster CA issues 90-day node certificates, renewed live and swapped into the running TLS config without a restart. The root rotates on a live cluster. Every internal connection is mutually authenticated, with the role carried in the certificate.

  • Secrets that never touch a disk

    Encrypted at rest in the Raft log, delivered over the mTLS dispatcher stream, materialised on a per-task tmpfs. A worker never writes a secret to its own storage.

How it works

One daemon, and nothing beside it

satld holds the API, the store, the scheduler, the orchestration loops and the agent. Adding a machine adds another copy of the same daemon — there is no second component to install and no separate control plane to operate.

A single SatL node: the CLI and the docker CLI speak the Docker Engine API over a unix socket to satld, which holds the Raft store, the scheduler and the orchestration loops; the dispatcher hands assignments to the agent, whose executor drives the image store, ZFS layers, the network layer and ocijail to produce a VNET jail.
  • The agent talks to the dispatcher over a socket even on a single node, using the same session protocol a remote worker uses. There is no shortcut path — which is why one node and a cluster behave identically.
  • The store is the only place cluster state lives. Every write goes through the leader; followers forward once.
  • Workers dial managers, never the reverse. The firewall rule is one-directional.

What happens when you create a service

satl service create --replicas 3 --publish 8080:80 web, end to end.

  1. The spec is committed

    The CLI posts to the Engine API. The leader commits the Service through Raft, and the allocators claim a published port, a subnet, a VNI and the task addresses in the same transaction.

  2. The orchestrator creates tasks

    The replicated orchestrator commits three Tasks in NEW. A task is one attempt at one replica on one node as one jail — immutable and one-shot. The slot is what carries the replica’s identity across replacements.

  3. The scheduler places them

    Filters first — readiness, resources, constraints, platform, host ports, replica caps — then spread ranking. Tasks move to ASSIGNED. Constraints are enforced continuously, so editing a node label moves running tasks.

  4. The agent builds the jail

    The dispatcher session delivers the assignment. The agent pulls the image, clones the layers, generates the OCI spec, creates the epair and the jail, and drives PREPARING → READY → STARTING → RUNNING. A periodic pass then re-derives the whole satl/rdr pf anchor from what is actually running locally.

What the model buys you

  • One orchestrator, or none, is not a choice you have to make

    No development mode that behaves differently from production. No compose file on one host and something else on three. No moment where you rebuild your mental model because you added a machine.

  • Desired state is the only state you set

    You do not start containers. You declare four replicas, and level-triggered loops make it true and keep it true. Nothing is triggered by an event alone.

  • Every lifecycle transition has a name

    Each one is a state-machine step, logged with its task, service and node identifiers. Diagnosis is grep-by-identity rather than inference.

  • The API stays honest

    Options SatL cannot honour are refused with a 400 and the reason named, not accepted and quietly ignored. A half-honoured isolation flag is a security trap.

A soft three-dimensional render: a single glowing daemon core with orbiting container modules, rendered in pastel apricot light against deep navy.

Networking

Overlays in VXLAN, the data path in pf

SatL owns the satl/* pf anchors and never writes a rule outside them. Everything a packet does — egress NAT, a published port, the mesh relay, the encrypted guard — is one of those anchors, re-derived from what is actually running.

Three FreeBSD nodes joined by a VXLAN overlay: each node has a bridge with epair-connected jails, and the nodes exchange encapsulated traffic over UDP port 4789 using a Raft-distributed forwarding table.
Three nodes on one VXLAN network: jails on a per-node bridge, each node an endpoint on the overlay. The overlay is the shared VNI, not a relay — traffic goes node to node.
The ingress routing mesh: a client reaching any manager on a published port is relayed through pf redirect rules to a live task on whichever node holds it, with return-path source NAT.
Every manager answers every published port, relaying to a live task.
  • Overlay MTU is measured, not assumed

    1450 on a 1500-byte underlay, because VXLAN costs 50 bytes; 1416 once a network is encrypted, because ESP adds a measured 34 on top. SatL derives it from the underlay it actually finds rather than from a constant.

  • The forwarding table is distributed, not learned

    One VNI per network, unicast, with static entries distributed through Raft and learning turned off. That is also why the usual 2000-endpoint ceiling does not apply — 2500 entries install cleanly at vxlanmaxaddr 2000.

  • Discovery is DNS round-robin

    FreeBSD has no IPVS, so there is no service virtual IP. A per-node DNS responder resolves a service name to its running tasks, shuffled per query. Clients that cache a single A record for a long time will notice.

  • Encryption is per network, and rotates itself

    Opt in with --opt encrypted: IPsec ESP with AES-128-GCM on the VXLAN data plane, keys delivered only to participating nodes, rotated automatically every twelve hours. A pf guard anchor drops cleartext on the encrypted ports.

Docker compatibility

The first hour is mostly familiar

The satl CLI speaks the verbs you already know — twenty-six of them — and satld serves the Docker Engine REST API v1.43 on a unix socket, negotiable down to 1.24. The right-hand column below is not a translation layer; it is the same command.

With DockerWith SatL
docker run -d -p 8080:80 nginxsatl run -d -p 8080:80 nginx
docker pssatl ps
docker logs -f websatl logs -f web
docker build -t app .satl build -t app .
docker service scale web=5satl service scale web=5
docker stack deploy -c compose.yaml shopsatl stack deploy -c compose.yaml shop

Or keep using the docker CLI

There is no shim and no wrapper. satld answers the Engine API, so point DOCKER_HOST at its socket and your existing tooling works unchanged.

    
      export DOCKER_HOST=unix:///var/run/satl.sockdocker versiondocker compose up -d
    
  

There is no TCP listener for the API, deliberately — the socket is the only surface, and it is group-owned rather than world-writable.

Where it differs, it says so

An option SatL cannot honour comes back as a 400 with the reason named, rather than being accepted and quietly ignored — Privileged, CapAdd, Devices, Sysctls, IPv6 subnets and the rest. A half-honoured isolation flag is a security trap, so SatL would rather fail your docker run than pretend.

Why FreeBSD

Every piece already existed

SatL invents no isolation primitive, no filesystem layer and no packet path. Jails, ZFS, if_bridge, pf, rctl and if_vxlan have been in the base system for years — SatL is the part that was missing: what drives them from a desired state.

What a container needs, what FreeBSD provides, and how SatL uses it
A container needsFreeBSD givesSatL uses it as
Isolationjail(8)One jail per task, with vnet for its own network stack
A layered filesystemZFSA layer is a dataset; applying one is a snapshot and a clone
Networkingif_bridge, epair, vnetOne bridge per network, one epair per task
The data pathpf(4)NAT for egress, rdr for published ports, inside SatL’s own anchors
Resource limitsrctl(8) / racctOne rule per container, added and removed with it
Cross-node networkingif_vxlan(4)One VNI per overlay, unicast, Raft-distributed forwarding table

Before you start

What the host has to be

Tested on FreeBSD 15.1 and CURRENT, amd64. The first five are hard requirements — the daemon either refuses to start without them or misbehaves in a way that looks like something else. The last four are things you will want before you trust it.

  • FreeBSD 15.1 or CURRENTrequired

    amd64. Tested on both. IPv4 only.

  • A ZFS pool and root datasetrequired

    Mandatory. satld refuses to start without it. Defaults to zroot/satl.

  • ocijailrequired

    pkg install ocijail — declared as a package dependency.

  • rootrequired

    The daemon creates jails, datasets, interfaces and pf anchors.

  • pf, loaded and enabledrequired

    pf is the data path, with the satl/* anchors declared in pf.conf.

  • kern.racct.enable=1

    A boot tunable, so it needs a reboot. Enforces --memory and --cpus; degrades gracefully without it.

  • gateway_enable=YES

    Container egress. Without it, published ports answer and containers reach nothing.

  • 2377/tcp, 2378/tcp, 4789/udp

    Clustering only. 2377 is mTLS, 2378 is CA bootstrap, 4789 is the VXLAN data plane.

  • Rust 1.96, edition 2024

    Only to build from source. pkg install rust gives exactly 1.96 — there is no margin.

A FreeBSD port and pkg install satl from the official repositories do not exist yet — the package on this site is self-contained and installs with no repository configured. Version0.1.0-beta.

Compared

Against Docker and Swarm

Several rows here are losses, and they stay in. A comparison that only lists wins tells you nothing you can plan against — and every one of these differences is already documented in full, numbered, on the docs site.

SatL compared with Docker and Docker Swarm
 Docker / SwarmSatL
Getting starteddocker swarm init requiredNothing to initialise — a cluster of one from first boot — a difference in SatL’s favour
Control planedockerd plus swarmkit, or etcdOne daemon, embedded Raft, nothing beside it — a difference in SatL’s favour
Standalone containersA separate mode with its own semanticsNone — every container is a task of a service — a difference in SatL’s favour
Storageoverlay2, plus a graph-driver choiceZFS, mandatory — no driver to pick, nothing to tune — a trade-off
Service load balancingAn IPVS virtual IPDNS round-robin — FreeBSD has no IPVS, so there is no VIP — a trade-off
Routing meshEvery node answers a published portEvery manager answers, through pf — a trade-off
Data pathiptablespf, confined to SatL-owned satl/* anchors — a difference in SatL’s favour
Memory limitcgroup throttle, then an OOM killrctl memoryuse:sigkill — a kill, with no throttling or reclaim — a trade-off
Resource resizeRolls the serviceHot resize — live rctl rewrite, same task IDs, no restart — a difference in SatL’s favour
Unhealthy containerLeft running, marked unhealthyStopped and replaced — health gates the RUNNING state — a difference in SatL’s favour
Unsupported optionsOften accepted and silently ignoredRefused with a 400 and the reason named — a difference in SatL’s favour
Unsupported compose keys“Ignoring unsupported options: …”The whole file is refused, key and line named — a difference in SatL’s favour
Join tokenSWMTKN-…SATL-1-<digest>-<secret> — the digest pins the whole root CA bundle — a difference in SatL’s favour
PlatformLinux, Windows, macOSFreeBSD 15.1 and CURRENT on amd64, IPv4 only — a trade-off

A tick marks a difference in SatL’s favour; a dash marks a trade you are accepting. Both are differences, and the docs explain the reasoning for each.

Clustering

Adding the second machine is a join, not a migration

There is no standalone mode to grow out of. The first node is already a cluster — of one — running the same Raft store, the same scheduler and the same dispatcher session protocol it will run with five. Nothing about it changes shape when the second one arrives.

  1. One node

    Install the package, write the config, start the daemon. It mints its identity and root CA, initialises a one-member Raft cluster, and begins scheduling. It is already a cluster — of one.

        
          service satld startsatl node ls
        
      

  2. A join token

    Ask the first node for one. The digest in the token pins the whole root CA bundle, so a joiner cannot be talked onto the wrong cluster.

        
          satl swarm join-token manager
        
      

  3. The second node

    A join, not a migration. Nothing about the first machine changes shape, and nothing you deployed on it needs rewriting.

        
          satl swarm join --token SATL-1-… node1:2377
        
      

A join sequence: node one holds the Raft store and the certificate authority and issues a join token whose digest pins the root CA bundle; node two presents it on the CA bootstrap port, receives a certificate, and then joins over mutual TLS.
The digest in a SATL-1-… token pins the whole root CA bundle, so a joiner cannot be talked onto the wrong cluster.

Managers run tasks too

A manager holds a Raft replica and elects a leader, and it also schedules and runs containers. Three managers is the recommended shape for a small cluster — it survives one loss, and quorum is what you pay for that.

Workers dial managers, never the reverse

Sessions are outbound from the worker, so the firewall rule is one-directional. The control plane is 2377 over mutual TLS; 2378 exists unauthenticated because a first-time joiner has no certificate yet.

Certificates renew themselves

Ninety-day node certificates, renewed at a random point inside a 50–80% window and swapped into the running TLS config with no restart. The root rotates on a live cluster through a cross-signed intermediate.

Questions

The ones people actually ask

Including the two that are not bugs.

Is this production-ready?

No, and it does not claim to be. This is a proof of concept at 0.1.0-beta: the feature set is complete enough to run real workloads — the documentation site’s Node.js and MariaDB tutorial runs end to end on a three-node cluster — but there has been no independent security audit, there is no compatibility promise between pre-1.0 versions, and no upgrade path across them. Run it where losing it is survivable.

Do my existing Docker Compose files work?

Standard Compose files, yes, with stack semantics rather than single-host ones. A file that uses keys SatL cannot honour is refused whole, with the key and the line named, rather than half-deployed with a warning you might miss.

Can I point the docker CLI at it?

Yes. satld serves the Docker Engine REST API v1.43 on /var/run/satl.sock, negotiable down to 1.24. Set DOCKER_HOST=unix:///var/run/satl.sock and your existing tooling talks to it unchanged. There is no TCP listener for the API, by design.

Can I run Linux images?

Yes. linux/amd64 images run under the linuxulator as a first-class fallback when no FreeBSD image exists, and both satl ps and satl images carry a PLATFORM column so you always know which you got.

What about IPv6?

Not yet — SatL is IPv4 only, with no IPv6 path anywhere. It is named on the status page as missing rather than planned-and-silent.

Why is ZFS mandatory?

Because a layer is not stored in ZFS, it *is* a dataset. Applying a layer is a snapshot and a clone, which is why there is no graph driver to select and nothing to tune. satld refuses to start without its root dataset rather than degrading into something slower and less predictable.

Why can’t I curl my published port from the host?

Because pf applies rdr to packets *entering* an interface, and traffic you originate on the publishing host never enters one. The redirect is correct; test it from another machine. This is the single most reported non-bug in SatL.

How do I upgrade?

For now, you do not — there is no supported upgrade path between pre-1.0 versions. A cluster wants the same build on every node, so fetch the package once, copy it around, and pkg add on each machine. Mixing versions across nodes is not a configuration SatL is tested in.

How do I report something?

A log excerpt and the command that produced it, on the engine’s issue tracker. That pair is worth more than a paragraph of description, and the documentation has a page on what makes a report useful. Security vulnerabilities go privately to security@satl.cc instead — never to an issue or a pull request.

Where it stands

Early, and real

0.1.0-betaProof of conceptFreeBSD 15.1 and CURRENT, amd64IPv4 only

This is a first version — a proof of concept — and the results so far are genuinely encouraging. The feature set already runs real workloads: the documentation site’s Node.js and MariaDB tutorial goes end to end on a three-node cluster. Everything described on this page and in the documentation has been run on FreeBSD 15.1 and CURRENT, not inferred from how it ought to behave.

Which is also why the list below exists. “Beta” here is a statement about the edges, not the middle, and the honest version of that statement is a list of names rather than a reassurance. The project will keep evolving — everyone is welcome to be part of that.

  • 18Rust crates
  • ~184klines of Rust
  • ~2300tests
  • 26CLI verbs

What is not there

  • No independent security audit

    The security model is written down in full, and the deliberate choices are named — port 2378 is unauthenticated by design, the daemon runs as root, /metrics is unauthenticated, there is no user-level authorisation in the API. None of it has been reviewed by anyone outside the project.

  • No upgrade path yet

    No compatibility promise between pre-1.0 versions, and no migration across them. Treat a version bump as a rebuild.

  • FreeBSD 15.1 and CURRENT, amd64, IPv4

    Nothing else is built or tested. No arm64 build, no cross-compilation, and no IPv6 anywhere.

  • Node-local volumes only

    No shared storage, no CSI, no cluster volumes. A stateful task is pinned to the machine holding its data, and placing it is your job.

  • Logs are per-container and node-local

    There is no cluster-wide satl service logs. You go to the node.

  • A permanently lost quorum cannot be repaired from inside

    There is a measured backup and restore procedure for the Raft dataset, but no satl verb performs it, and a cluster that loses quorum for good is rebuilt rather than recovered.

  • No CI, and one maintainer

    Opening a pull request runs nothing. The build and the tests need FreeBSD 15.1 with ZFS, jails, pf and ocijail, which no hosted runner offers, so make check on a real host is the only gate — and running it is the contributor’s job.

  • Not for hostile multi-tenant workloads

    Stated plainly in the security policy. Isolation options SatL cannot honour are refused rather than faked, but that is not the same as being hardened against a tenant who is trying.

Get involved

Everyone is welcome

This is a first version — a proof of concept — and the results so far are genuinely encouraging. It will keep evolving, and anyone who wants to be part of that is welcome.

The most useful thing you can send back

Run it on FreeBSD 15.1 or CURRENT and tell us what happened. A log excerpt and the command that produced it is worth more than a paragraph of description — the documentation has a whole page on what makes a report useful, because a good report is the difference between a fix and a guess.

There is no CI, deliberately

Opening a pull request runs nothing — no Actions, no checks tab. The build and the tests need FreeBSD with ZFS, jails, pf and ocijail, which no hosted runner offers. So make check on a real host is the only gate, and pasting its output into the pull request is what makes the change reviewable. Networking, runtime and storage changes also want sudo make integration; cluster behaviour wants make cluster-test.

Where the work is

The roadmap names what is missing rather than hiding it — IPv6, shared storage, cluster-wide logs, a real FreeBSD port. Those are the honest starting points. The contributor guide lives in the repository.

Vulnerabilities go privately

Not to an issue and not to a pull request: security@satl.cc. Scope and expectations are in the repository’s security policy, including the choices that are deliberate and therefore not findings.

Try it on a machine you can afford to lose

A self-contained FreeBSD package. It needs no repository, installs four files, and pulls ocijail if you have a package repository configured. Then read the host preparation steps — the daemon starts without several of them and degrades quietly.

    
      fetch https://satl.cc/download/satl-freebsd.pkgpkg add ./satl-freebsd.pkg
    
  

FreeBSD 15.1 and CURRENT, amd64. ZFS mandatory. IPv4 only. BSD-2-Clause.

A soft three-dimensional render: a single container module descending toward a planet surface on a beam of pastel apricot light.