FreeBSD jail.conf Lifecycle Hooks: Startup Ordering, Failure Cleanup, and Teardown
Use FreeBSD jail lifecycle hooks deliberately: map host and jail phases, order dependencies, bound commands, and recover safely from partial starts or stops.
FreeBSD’s jail.conf hooks let the host coordinate preparation, jail creation, service startup, shutdown, and cleanup. Their value is not that they turn jail startup into a transaction; they do not. The value is that they provide named lifecycle boundaries where an operator can run small, observable, repeatable commands. A hook that creates a host interface, mounts data, or stops a service can leave real state behind if a later phase fails. Treat every hook as an independently recoverable operation rather than a line in an automatically rolled-back deployment script.
This guide focuses on the native jail(8) lifecycle. It is not an iocage tutorial, and it does not replace an application’s own readiness or backup procedures. A jail can be correctly created while its application is unhealthy, and a successful stop command does not prove that external state was cleaned up.
Map each hook to its execution environment
The exec.* parameters are command lines run by jail(8) in either the host’s system environment or the jail environment. Multiple values for a parameter run sequentially. Every command must exit successfully; a nonzero status prevents the jail from being created or removed, as appropriate. This makes exit codes part of the lifecycle contract, not merely logging details.
| Phase | Environment | Appropriate responsibility |
|---|---|---|
exec.prepare |
Host, before jail IP assignment and filesystem mounts | Create or validate the jail’s filesystem root and required host-side prerequisites |
exec.prestart |
Host, before the jail is created | Prepare external resources that must exist before kernel jail creation |
exec.created |
Host, after creation but before in-jail commands | Apply a narrowly scoped host-side action that requires the jail to exist |
exec.start |
Inside the jail | Start the jail’s service set, commonly through /bin/sh /etc/rc |
exec.poststart |
Host, after exec.start completes |
Run a bounded check or publish a host-side status signal |
exec.prestop |
Host, before jail removal | Stop admitting new work or ask an external load balancer to drain the service |
exec.stop |
Inside the jail, after exec.prestop |
Request orderly shutdown, commonly through /bin/sh /etc/rc.shutdown jail |
exec.poststop |
Host, after the jail has been removed | Remove temporary host-side state that does not depend on mounted jail filesystems |
exec.release |
Host, after the remaining teardown, including unmounting filesystems and removing jail addresses | Release a disposable jail filesystem only when its data lifecycle explicitly permits it |
These phases are not interchangeable. For example, an exec.poststart command runs after the configured exec.start commands return; that ordering does not prove that an application finished warming caches or passed a health check. Conversely, a hook that edits a file inside the jail cannot run in the host environment merely because the file is visible below the jail root. Choose the hook from the resource’s ownership boundary, then verify the result independently.
Keep hooks bounded, explicit, and repeatable
Start with a configuration whose host-side scripts are small and version-controlled. This example is a template, not a ready-to-run jail: the paths, backend jail, and scripts must already exist and be reviewed for the host.
app {
path = "/usr/local/jails/app";
host.hostname = "app.example.test";
persist;
exec.clean;
exec.timeout = 30;
stop.timeout = 45;
# The dependency is another jail defined in the loaded configuration.
depend = "backend";
exec.prepare = "/usr/local/libexec/jail-app-prepare";
exec.prestart = "/usr/local/libexec/jail-app-prestart";
exec.created = "/usr/local/libexec/jail-app-created";
exec.start = "/bin/sh /etc/rc";
exec.poststart = "/usr/local/libexec/jail-app-check";
exec.prestop = "/usr/local/libexec/jail-app-drain";
exec.stop = "/bin/sh /etc/rc.shutdown jail";
exec.poststop = "/usr/local/libexec/jail-app-poststop";
exec.release = "/usr/local/libexec/jail-app-release";
}
The depend parameter describes a relationship between jails, not a general service-health graph. If backend is absent when app is created, jail(8) creates the dependency and waits through its final exec.poststart command before creating app; if backend already exists, the dependency declaration does not rerun its startup hooks or prove readiness. On removal, app proceeds through its final exec.poststop command before backend is stopped. This is useful for a jail-to-jail ordering dependency, but it does not prove that a daemon inside backend is healthy or that a host service is ready. Put an actual readiness check in a bounded check command and make it test the required endpoint.
The default exec.timeout is not an invitation to let a hook hang indefinitely. The manual defines it as the maximum time to wait for an exec.* command; if the command exceeds the timeout, the jail is not created or removed as appropriate. Keep hooks short, capture stdout/stderr with exec.consolelog or a root-owned logging path where appropriate, and make failure messages identify the phase and resource. stop.timeout is a separate control: after exec.stop completes, jail(8) sends SIGTERM, waits for the configured period, and then removes the jail, killing remaining processes. The default is ten seconds; a longer grace period should match the application’s actual shutdown contract.
exec.clean removes most of the caller’s environment and sets a documented minimal environment. That improves predictability, but scripts must not silently depend on shell startup files, a custom PATH, or inherited secrets. In particular, JID, JNAME, and JPATH are not set when exec.clean is used. Pass required values explicitly and use fixed, root-owned script paths. A configuration value that becomes part of a shell command must not contain untrusted input.
Understand partial failure instead of assuming rollback
If exec.prepare or exec.prestart creates external state and a later hook fails, that state may need explicit compensation. The jail(8) contract requires hook commands to succeed, but it does not make arbitrary host actions, such as an interface change, a DNS update, a load-balancer request, or a dataset operation, part of one atomic transaction. Make prepare and cleanup steps idempotent: a retry should detect an already-created resource, validate that it belongs to this jail, and either continue safely or report a specific conflict.
Do not erase a resource merely because a cleanup hook ran. Before a script removes a path, interface, mount, or dataset, confirm that the object is the one created for this jail and that it is no longer shared. In particular, exec.release runs after mounts and assigned addresses have been removed; that can be an appropriate place to release an explicitly disposable jail root, but not a reason to delete persistent application data. Keep jail user data in a separately managed dataset with its own retention and backup policy.
An error during a stop hook deserves the same care. A nonzero exec.prestop or exec.stop can prevent removal, so first inspect whether the jail still exists, which processes remain, and which cleanup steps completed. exec.poststop runs after the jail has been removed, and exec.release runs after teardown actions such as unmounting filesystems and removing IP addresses; a failure in either later hook requires reconciliation, but does not mean the jail is still running. jls -n -j app provides the current jail parameters; inspect the service’s own health and logs before retrying. Do not use jail -R as a routine “force stop”: the manual says that -R removes without using the configuration file and does not apply the removal-related parameters. It can bypass the hooks intended to drain services or release external resources. Use that escape hatch only under a separately reviewed emergency procedure, followed by explicit reconciliation of every host-side effect.
Test startup and shutdown as separate failure paths
Test on a disposable jail or maintenance host with no production dataset or shared network state. Run one lifecycle at a time, capture the verbose operations, and verify both the jail and its application:
jail -c -v app
jls -n -j app
# Check the service from an approved health endpoint or a jail-local probe.
# Then test the configured orderly stop path.
jail -r -v app
jls -j app
Exercise at least these cases before enabling boot-time start: a successful create/remove cycle; a deliberately failing exec.poststart; a slow hook that reaches the timeout; a service that exits before the expected stop phase; a dependency that is absent and must be started; and a repeated start after a partial failure. The expected outcome is not always “the jail disappeared”: confirm the documented command result, running jail state, mounted filesystems, addresses, and external resources after each scenario.
The rc.conf(5) jail_list variable controls which configured jails start through the jail rc script and in what order; jails omitted from a non-empty list must be started manually. The depend parameter can override that list by bringing in required jails, and jail_reverse_stop affects the listed stop order. Test a cold host boot and an orderly host shutdown, not just an interactive jail -c. Do not start the same jail through both service jail and another manager such as iocage.
FreeBSD jail hooks are most reliable when they describe ownership and sequencing, not hidden orchestration. Keep each command bounded, make side effects discoverable, test failed phases deliberately, and treat a successful hook as one piece of evidence, not proof that the entire service is ready or fully cleaned up.
Related:
- FreeBSD Jails: Lightweight OS-Level Virtualization Done Right
- FreeBSD’s rc.d Init System: Scripts, Dependencies, and Service Ordering
Sources: