Recipes
Copy-paste setups for common stacks. Each snippet is one entry in the apps list of your watchd.lua.
Go API with a build step
go run works, but a separate build step makes compile errors easier to spot and leaves you a reusable binary:
{
name = "api",
cmd = "go build -o ./tmp/api ./cmd/api && ./tmp/api",
watch = { "**/*.go", "go.mod" },
ignore = { "tmp" },
healthcheck = { http = "http://127.0.0.1:8080/healthz" },
},Node, Vite or Next.js frontend
Dev servers like Vite and Next.js already reload in the browser when you edit code, so don't watch the source files. Let watchd keep the process alive, and restart it only when dependencies or config change:
{
name = "web",
cmd = "npm run dev",
cwd = "./web",
watch = { "package.json", "vite.config.*" },
env = { PORT = 3000 },
healthcheck = { port = 3000 },
},For a plain Node server without its own reloading, watch the sources:
{
name = "server",
cmd = "node server.js",
watch = { "**/*.js", "package.json" },
ignore = { "node_modules" },
},Python: Django, Flask, FastAPI
Turn off the framework's own reloader and let watchd do the restarting, so two reloaders don't fight:
{
name = "django",
cmd = "python manage.py runserver --noreload 0.0.0.0:8000",
cwd = "./backend",
watch = { "**/*.py" },
ignore = { ".venv", "__pycache__" },
env_file = "backend/.env",
healthcheck = { port = 8000 },
},{
name = "api",
cmd = "uvicorn app.main:app --port 8000", -- without --reload
watch = { "**/*.py" },
ignore = { ".venv", "__pycache__" },
healthcheck = { http = "http://127.0.0.1:8000/docs" },
},{
name = "flask",
cmd = "flask --app app run --port 5000", -- without --debug's reloader
watch = { "**/*.py", "templates/**/*.html" },
ignore = { ".venv", "__pycache__" },
},Apps inherit the environment of the shell that ran watchd up, so activate your virtualenv first. Alternatively, point at its Python directly: cmd = ".venv/bin/python manage.py ...".
Rust
{
name = "server",
cmd = "cargo run --bin server",
watch = { "src/**/*.rs", "Cargo.toml" },
ignore = { "target" },
stop_timeout = 5,
},Local Postgres and Redis, without Docker
Make other apps depend on them so they start only once the database accepts connections:
{
name = "postgres",
cmd = "postgres -D ./.data/pg -p 5432",
healthcheck = { cmd = "pg_isready -h 127.0.0.1 -p 5432", interval = 1 },
stop_signal = "SIGINT", -- Postgres "fast shutdown"
stop_timeout = 30,
},
{
name = "redis",
cmd = "redis-server --port 6379 --save ''",
healthcheck = { cmd = "redis-cli -p 6379 ping" },
},
{
name = "api",
cmd = "go run ./cmd/api",
depends_on = { "postgres", "redis" },
},Create the Postgres data directory once with initdb -D ./.data/pg.
Using Docker for some services
watchd can supervise docker run like any other command. Use --rm and a fixed container name so restarts don't pile up containers:
{
name = "db",
cmd = "docker run --rm --name shop-db -p 5432:5432 -e POSTGRES_PASSWORD=dev postgres:16",
healthcheck = { port = 5432 },
stop_timeout = 20,
},Apps you only need sometimes
autostart = false keeps an app out of a plain watchd up:
{ name = "storybook", cmd = "npm run storybook", cwd = "./web", autostart = false },Start it when you need it with watchd start storybook (or watchd up storybook).
Scripts that run once
For setup steps such as migrations or code generation, use restart = "never". A clean exit shows as exited (exit 0) in watchd ps:
{
name = "migrate",
cmd = "go run ./cmd/migrate up",
restart = "never",
depends_on = { "postgres" },
},Run a task before starting an app
An app can depend on a one-off task. It starts once the task finishes successfully (exit code 0), similar to Docker Compose's service_completed_successfully:
{ name = "postgres", cmd = "postgres -D ./.data/pg", healthcheck = { cmd = "pg_isready -h 127.0.0.1" } },
{ name = "migrate", cmd = "go run ./cmd/migrate up", restart = "never", depends_on = { "postgres" } },
{ name = "api", cmd = "go run ./cmd/api", depends_on = { "migrate" } },The order is: postgres starts → once healthy, migrate runs → once it exits with code 0, api starts.
If the migration fails, migrate shows as failed and api keeps waiting. Fix the problem and run watchd restart migrate. When it succeeds, api starts by itself.
TIP
Use restart = "never" (or the default "on-failure") for tasks. With "always", the task would run again every time it finishes.
Many similar services
The config is Lua, so you can generate repetitive entries with a loop:
local services = { "users", "orders", "billing" }
local apps = {}
for i, svc in ipairs(services) do
table.insert(apps, {
name = svc,
cmd = "go run ./cmd/" .. svc,
env = { PORT = 9000 + i },
watch = { "cmd/" .. svc .. "/**/*.go", "internal/**/*.go" },
healthcheck = { port = 9000 + i },
})
end
return { name = "platform", apps = apps }Personal overrides without editing the shared config
Read values from your environment, with defaults:
local api_port = watchd.env("API_PORT", "8080")
return {
apps = {
{ name = "api", cmd = "go run .", env = { PORT = api_port } },
},
}API_PORT=9090 watchd up # or set API_PORT in your shell profileSkip restarts for some files
Use an on_change hook to ignore changes that don't affect the running app:
{
name = "api",
cmd = "go run .",
watch = { "**/*.go", "**/*.md" },
on_change = function(file)
-- tests and docs don't change the running server
return not (file:match("_test%.go$") or file:match("%.md$"))
end,
},Graceful shutdown with a custom signal
Some servers drain connections on SIGINT or SIGQUIT instead of SIGTERM:
{
name = "worker",
cmd = "bundle exec sidekiq",
stop_signal = "SIGQUIT",
stop_timeout = 25, -- allow time to finish in-flight jobs
},