Plugins¶
Compose Farm assumes stack directories are shared over NFS and secrets live in .env files. Plugins let you replace those assumptions: copy compose files with rsync, give containers secrets decrypted by the host (agenix, sops-nix), or move per-stack ZFS datasets along with a migrating stack.
Enabling plugins¶
Plugins are listed under plugins: in the config. Each key is a plugin name, each value its options (or nothing). Plugins run in the order they are listed.
An unknown plugin name or invalid options make config loading fail, so cf config validate and cf check catch mistakes early. cf check also lists the enabled plugins.
Plugins run wherever cf runs, including the web UI (which runs cf for its actions). The Docker image includes the builtin plugins and the example plugins; for others, build your own image that installs them next to compose-farm (uv tool install "compose-farm[web]" --with <plugin>).
Builtin plugins¶
commands¶
Runs shell commands from the config at lifecycle hooks and adds extra docker compose arguments. No Python needed.
plugins:
commands:
before_up:
- run: "mkdir -p /srv/data/{stack}" # on the hook's host, over compose-farm's SSH
- local: "./scripts/notify.sh {stack}" # on the machine running cf
after_source_stopped: []
after_up: []
after_stack_removed: []
preflight:
- run: "test -r /run/agenix/{stack}.env" # non-zero exit = preflight problem
compose_args: ["--env-file", "/run/agenix/{stack}.env"]
after_changes: # once per cf command, on the machine running cf
- local: "cd {compose_dir} && ./scripts/kuma-sync.py sync --apply"
- Each step is
run:(runs on the host the hook is for) orlocal:(runs wherecfruns).after_changessteps are alwayslocal:because that hook is not tied to a host. - A failing step fails the hook (see Hooks for what that means per hook). Preflight steps never fail the hook; each failing check is reported as a problem.
- Placeholders:
{stack},{host},{source_host}(empty unless migrating),{compose_dir},{stack_dir}. Inrun/localcommands the values are inserted already shell-quoted, so don't wrap placeholders in quotes yourself (echo {stack}, notecho '{stack}').compose_argsitems are substituted as-is and each item is quoted when the compose command is built.after_changessteps have{stacks}(the changed stacks, each quoted) and{compose_dir}instead. Unknown placeholders, conversions ({stack!r}), and format specs ({stack:>9}) are config errors.
sync¶
Copies compose_dir/<stack>/ from the machine running cf to the same path on the target host before every start, using rsync over compose-farm's SSH settings (key, known_hosts, port). This replaces NFS for compose files.
plugins:
sync:
excludes: [".git", "*.tmp"] # rsync --exclude patterns
delete: false # rsync --delete (default: false)
compose_dirmust exist locally at the same path it has on the hosts.- Hosts that are the local machine are skipped.
rsyncmust be installed on the machine runningcf(the Docker image includes it) and on the hosts.- The local copy is the source of truth. Every file that exists locally replaces the host's version whenever they differ, even if the host's file is newer. Keep runtime data (
./data,./configbind mounts) and host-specific files (a per-host.env) out of the stack directory, or list them inexcludes. - Careful with
delete: true: it also removes every file on the host that is not in the local copy, including such data and host-only files. Only enable it together withexcludesfor those paths.
Hooks¶
Hooks run in config order. The first failure stops the chain for that call, except for preflight (problems from every plugin are collected), after_up and after_changes (every plugin still runs; failures are warnings).
| Hook | Called | On failure |
|---|---|---|
before_up |
Before preflight on the target host, on every start (up, update, apply, including --service and --host). During a migration the source is still running. |
This stack is not started; the source is untouched |
preflight |
During preflight (up) and cf check. Must not change anything. |
Returned problems are reported like missing paths |
after_source_stopped |
Migration only: the source is stopped, the target not started yet | Rollback: the stack is restarted on the source if it was running there |
after_up |
The stack started on the host (after the state update) | Warning only |
after_stack_removed |
An orphaned stack (removed from config) was stopped via cf down --orphaned or cf apply. Not called for strays or a plain down |
Later plugins are skipped, and the stack stays in the state file, so the next cf down --orphaned/cf apply retries |
after_changes |
Once per cf up/update, down (including --orphaned), or apply that started, moved, or stopped stacks, after all of them. Gets a ChangesContext with the changed stacks. For plugins that act on the whole deployment (DNS records, monitors) |
Warning only |
compose_args |
Every time a compose command is built for a stack on a host (up, down, ps, logs, pull, restart, compose, ...) |
On start, the stack fails before anything is stopped or started; other commands abort with the error |
Migrating a stack runs:
before_upon the target (source_hostset, source still running)- Preflight on the target
- Pull and build on the target
docker compose downon the sourceafter_source_stoppeddocker compose up -don the targetafter_upon the target (source_hostset)
Multi-host stacks run before_up and preflight on every host before starting any host, then after_up for each host that started. They never migrate, so source_host is always empty.
stop, restart, plain down, and cf compose do not run lifecycle hooks, but they do get compose_args.
Writing a plugin¶
A plugin is a Python class registered under the compose_farm.plugins entry-point group. Override any subset of the hooks.
import shlex
from compose_farm.plugins import HookContext, Plugin, PluginError
class DataDirPlugin(Plugin):
"""Create /srv/data/<stack> on the target host before each start."""
def __init__(self, options):
super().__init__(options)
self.root = options.get("root", "/srv/data")
if not self.root.startswith("/"):
raise PluginError("root must be an absolute path")
async def preflight(self, ctx: HookContext) -> list[str]:
result = await ctx.run(f"test -w {shlex.quote(self.root)}", stream=False, check=False)
return [] if result.success else [f"{self.root} is not writable"]
async def before_up(self, ctx: HookContext) -> None:
await ctx.run(f"mkdir -p {shlex.quote(f'{self.root}/{ctx.stack}')}", stream=False)
# pyproject.toml of your package
[project.entry-points."compose_farm.plugins"]
datadir = "my_package:DataDirPlugin"
Install the package next to compose-farm (for example uv tool install compose-farm --with my-package) and enable it with plugins: {datadir: {root: /srv/data}}. See Example plugins for complete ones.
HookContext has:
cfg: the loadedConfigstack,host: the stack and the host this call is for (the target for up hooks)source_host: the previous host during a migration, otherwiseNone. It is set even if that host is no longer in the config.await ctx.run(command, host=None, stream=True, check=True): run a shell command onhost(defaultctx.host) through compose-farm's SSH. Withcheck=Truea non-zero exit raisesPluginError.await ctx.run_local(command, stream=True, check=True): the same on the machine runningcf.
after_changes gets a ChangesContext instead, with cfg, stacks (the stacks the command started, moved, or stopped), await ctx.run(command, host=..., ...) (the host is required), and await ctx.run_local(...).
Raise PluginError (or any exception) to fail a hook; the message is shown to the user.
Rules for plugins:
- Idempotent: a hook can run again for the same stack and host after a failure or retry.
- Keep the source intact in
before_upandafter_source_stopped. Irreversible cleanup of the source belongs inafter_up. - Refuse when the source is unreachable: if your plugin needs the source (for example to copy data) and
ctx.source_host not in ctx.cfg.hosts, raise inbefore_upinstead of starting from scratch. - Don't treat a missing
source_hostas proof of a first deploy:cf downremoves the stack from the state file, so a latercf upon another host has nosource_host. A data-moving plugin should check whether the data already exists elsewhere before creating it empty. after_stack_removedneeds the stack directory: it runs afterdocker compose downsucceeds in that directory, and a retry runsdownagain. If your plugin removes or renames the directory, list it last, so a failure in another plugin does not leave the stack stuck in the state file.- No blocking calls: hooks for different stacks run concurrently. Use
ctx.run/ctx.run_localor asyncio subprocesses. compose_argsstays cheap: it is called for every compose command, so no remote commands or slow work there.
Example plugins¶
Complete, installable plugins live in examples/plugins/. Use them as-is or as a starting point:
| Plugin | What it does |
|---|---|
| agenix | Passes host-decrypted secret env files (/run/agenix/...) to compose as --env-file for the stacks you list, and checks during preflight that every secret exists on the target host |
| zfs | A ZFS dataset per stack: created on first deploy, moved with zfs send/recv on migration (live send, then a short final incremental), retired on removal. A storage_host mode keeps all datasets on one NAS instead |
| pin | Keeps stacks on the host they must run on (static IPs, USB devices, GPUs): starting one elsewhere fails with the reason, and cf check flags a pin that no longer matches stacks: |
| traefik-dns | After every change, writes one DNS record per Traefik Host() name under a domain into a managed block (Headscale extra_records or hosts lines) and restarts the reading stack if the records changed |
| traefik-policy | Checks Traefik router labels in preflight, e.g. that a router on a public entrypoint is also on websecure |
Install one next to compose-farm:
uv tool install compose-farm \
--with "compose-farm-zfs @ git+https://github.com/basnijholt/compose-farm#subdirectory=examples/plugins/zfs"
Without installing anything, the commands plugin covers simple cases. For example, a secret env file for every stack:
plugins:
commands:
preflight:
- run: "test -r /run/agenix/{stack}.env"
compose_args: ["--env-file", ".env", "--env-file", "/run/agenix/{stack}.env"]
compose_args from commands apply to every stack, so every stack then needs both .env and /run/agenix/<stack>.env (compose fails if a listed file is missing). The agenix example plugin avoids that by only touching the stacks you list.
More commands recipes:
plugins:
commands:
before_up:
# Remount NFS after the NAS rebooted (needs passwordless sudo for mount)
- run: "mountpoint -q /mnt/data || sudo mount /mnt/data"
# Create the shared Docker network if this host doesn't have it yet
- run: "docker network inspect mynetwork >/dev/null 2>&1 || docker network create --subnet 172.20.0.0/16 --gateway 172.20.0.1 mynetwork"
after_changes:
# Sync Uptime Kuma monitors once per command, not once per stack
- local: "cd {compose_dir} && ./scripts/kuma-sync.py sync --apply"
Security¶
compose_args must contain file paths and flags, never secret values. Compose commands are printed to the terminal, stored in the web UI's task logs, and visible in ps on the host.
The commands plugin runs shell commands from the config file. The config already controls SSH access to every host, so this adds no new trust boundary.
Limitations¶
- Compose Farm parses each stack's compose file and
.envlocally for preflight paths, ports, and Traefik labels. That parsing does not seecompose_args: variables that only exist in an extra env file are not visible there, and services, volumes, or labels added through extra-ffiles are not reflected in preflight or Traefik output. - Avoid
-fand-pincompose_args: a single-freplaces compose's file discovery (list the stack's own compose file too), and-pchanges the project name, whichcf refreshand stray detection rely on (they expect the directory name). cf checkruns preflight without runningbefore_upfirst. Paths that a plugin creates on start are therefore reported as missing on hosts that never ran the stack:compose_dirwithsync, and volume paths with per-stack datasets.cf upcreates them.- Plugins cannot add CLI commands yet.