File Watching
Process Compose can restart a process when its source files change, and - with cascade - restart everything that depends on it. This replaces wrapping each command in a per-language watcher (air, nodemon, watchexec, cargo-watch).
Wrapping has a cost that is easy to miss: the wrapper becomes the process Process Compose manages, so shutdown.signal, success_exit_codes and PTY handling apply to the wrapper rather than to your program, liveness_probe measures the wrapper's lifetime (it never dies, so a crash-looping app looks healthy), and the Restarts counter never moves. A built-in watch keeps all of those pointed at the real process - and, unlike any per-language watcher, it knows your dependency graph.
Configuration
Add a watch section to a process:
processes:
api:
command: "go build -o bin/api ./cmd/api && ./bin/api"
watch:
debounce: 300ms
paths:
- path: ./src
exclude: ["**/*_test.go", "**/testdata/**"]
Saving anything under ./src restarts api, except test files.
Watch Options
| Option | Type | Default | Description |
|---|---|---|---|
paths |
list | - | Watched roots and their filters. Required |
debounce |
string | 300ms |
Go duration to let a burst of changes settle before restarting |
cascade |
bool | false | Also restart this process's dependents, transitively |
max_entries |
int | 8192 | Cap on watched directories |
buffer_size |
int | 65536 | Event buffer size, per watched directory. Windows only |
disable_default_excludes |
bool | false | Turn off the built-in ignore list |
Path Options
| Option | Type | Default | Description |
|---|---|---|---|
path |
string | - | Directory to watch, resolved against the process working_dir. The whole subtree is watched |
include |
list | - | If set, only matching files trigger a restart |
exclude |
list | - | Matching files are ignored and matching directories are not descended into |
Pattern Matching
Patterns use doublestar glob syntax, where ** matches across directories. One rule decides what a pattern is matched against:
- a pattern containing
/is matched against the path relative to the watched root -generated/** - a pattern without
/is matched against the file's base name, at any depth -*_test.go
So exclude: ["*_test.go"] correctly excludes pkg/inner/foo_test.go, and exclude: ["generated/**"] excludes only that subtree.
Default Excludes
Unless disable_default_excludes is set, these are ignored: .git, .hg, .svn, .idea, .vscode, node_modules, vendor, target, dist, build, bin, .venv, venv, __pycache__, .mypy_cache, .pytest_cache, .next, .nuxt, .terraform, .gradle, .direnv, result, plus *.log, *.log.gz, editor swap files and .DS_Store.
Process Compose's own log files - the global log and any log_location, including rotated .gz siblings - are always excluded, even with disable_default_excludes. Without that, a watch on the project directory would retrigger on the project's own output.
Cascading Restarts
cascade: true restarts the watched process and then everything that depends on it, in dependency order.
processes:
assets:
command: "make assets"
availability:
restart: "no" # one-shot
watch:
cascade: true
paths:
- path: ./assets
api:
command: "go build -o bin/api ./cmd/api && ./bin/api"
depends_on:
assets:
condition: process_completed_successfully
watch:
paths:
- path: ./src
- Editing
./assets/app.scssre-runsmake assets, then restartsapi. - Editing
./src/main.gorestartsapialone.
[!IMPORTANT] Restarts propagate downstream to dependents, never upstream to dependencies. A change to
apiwill not re-runassets. If a change should rebuild an artifact and then restart its consumers, put the watch on the process that produces the artifact and setcascade: truethere.
Process State
A process that has exited successfully but still has an armed watch reports the Watching state rather than Completed, in the TUI, in process-compose process list and over the REST API. It signals that the process is waiting for a file to change - and explains why the project is still running.
A process that failed keeps Failed or Error. Watching never stands in for a failure: a broken build is the most important thing a watch loop has to report, and the watch stays armed either way, so the next save still triggers a rebuild.
The TUI also reports each watch-triggered restart in the status bar, naming the file that caused it. Bursts are folded into a single summary so that rapid restarts stay readable.
Project Lifetime
While any watch is armed, the project stays up even after every process has exited - otherwise a project of one-shot builders would exit before a watch could ever fire.
Stopping a process pauses its watch, so a stopped process is never brought back by a file change. Starting it again resumes the watch.
This works on a process that has already exited: stopping something in the Watching state disarms its watch, which is how you leave that state. The process drops back to Completed, and if nothing else is running or armed the project then finishes.
To run without watching at all - in CI, or in a script - use --no-watch (or PC_NO_WATCH). Then up behaves exactly as it did before watching existed, watch blocks are ignored, and the project exits when its processes finish.
Feedback Loops
The classic trap is a process that writes into a directory it also watches, so each restart triggers the next. The default excludes cover the usual build output directories, but if a loop does occur Process Compose detects it, logs an error naming the process and the file that triggered it, and suspends that watch until the project is reloaded rather than looping forever.
If you hit this, narrow paths or add an exclude for your build output.
Unsupported Combinations
watch cannot be combined with:
| Setting | Reason |
|---|---|
replicas > 1 |
Each replica would install its own watcher on the same tree, multiplying descriptors and restarts |
schedule |
A restart does not reschedule the cron job, and scheduler concurrency limits would not see watch-driven starts |
is_foreground |
Foreground processes run inside the TUI with the terminal suspended, and are not managed as ordinary processes |
Template variables ({{.Vars.x}}) are also not supported in watch paths and are rejected at load time.
Platform Notes
File watching uses fsnotify - inotify on Linux, kqueue on macOS and BSD, ReadDirectoryChangesW on Windows.
- macOS and BSD consume one file descriptor per watched file. Large trees can exhaust the limit, so keep
pathsnarrow and useexclude. - Linux limits total watches via
fs.inotify.max_user_watches. Exceeding it is reported with a message naming the sysctl to raise. - Windows uses a fixed event buffer that can overflow under heavy churn; raise
buffer_sizeif changes are missed. The buffer is allocated per watched directory, so when two processes watch the same directory the larger of their two settings applies to it. - Network and virtual filesystems (NFS, SMB, FUSE,
/proc,/sys) do not deliver events, so watching them silently does nothing. This includes WSL2 paths under/mnt/c. - Symlinked directories are not followed.
Watching a single file is supported, but its parent directory is watched instead and filtered to that name - a watch on a file alone would be destroyed the first time an editor saved it by writing a temporary file and renaming it into place.