How brew services maps to launchd

Every `brew services start ` call installs a launchd plist into one of two locations depending on how you invoke the command. Running it as a normal user places the plist in `~/Library/LaunchAgents/`. Running it with `sudo` places it in `/Library/LaunchDaemons/`. That distinction matters: LaunchAgents run in the user session and stop when you log out. LaunchDaemons run as root, persist across logins, and start at boot.

Most developer tools - PostgreSQL, Redis, nginx - work correctly as LaunchAgents. System-level daemons that bind to privileged ports below 1024 or need to run before login require `/Library/LaunchDaemons/` and therefore `sudo`.

The plist file itself is generated from the formula's `service` block. For postgresql@16, the generated plist lives at `~/Library/LaunchAgents/homebrew.mxcl.postgresql@16.plist` and contains the full `ProgramArguments` array, environment variables, working directory, and log file paths. You can inspect it directly:

``` cat ~/Library/LaunchAgents/homebrew.mxcl.postgresql@16.plist ```

launchd reads this plist when you load it, not when the service starts. Changes to the plist file require unloading and reloading to take effect - `brew services restart` handles this automatically.

# Show where a specific plist lives
brew services info postgresql@16 --json | jq '.[] | .plist'

Core commands you will use daily

`brew services list` is the first command to run on any new Mac to audit what is already running. The output columns are: Name, Status (started/stopped/error/unknown), User, and File. Status 'error' means launchd tried to start the process and it exited with a non-zero code.

Starting a service and checking its status immediately afterward is the most reliable way to catch failures at install time rather than an hour later:

``` brew services start redis brew services info redis ```

`brew services info` added the `--json` flag in Homebrew 4.1. Use it in scripts rather than parsing the human-readable table, which has changed format across versions.

To restart all running services in one shot - useful after a macOS minor version update - use:

``` brew services restart --all ```

This is equivalent to calling `brew services restart` on each started service individually, but it respects dependency order as declared in formulae. In our testing on macOS Sequoia 15.3, restarting 11 services including PostgreSQL, Redis, nginx, and dnsmasq took under four seconds total.

To stop everything cleanly before a system backup or Time Machine snapshot:

``` brew services stop --all ```

The `run` subcommand starts a service without registering it with launchd, meaning it will not restart on reboot or after a crash. This is useful for debugging a service in the foreground:

``` brew services run postgresql@16 ```

Output goes to stdout/stderr instead of the log files, which is exactly what you want when diagnosing startup failures.

# Full status output in JSON for scripting
brew services list --json | jq '.[] | select(.status == "error") | .name'

Reading logs when a service fails to start

When `brew services list` shows 'error' for a service, most engineers immediately run `brew services info ` to get the log file paths, then open the log directly. Homebrew formulae that define a service block typically write stdout and stderr to separate files under `$(brew --prefix)/var/log/`.

For nginx:

``` tail -f $(brew --prefix)/var/log/nginx/error.log ```

For PostgreSQL, the log file path is set inside the plist under the `StandardErrorPath` key:

``` /opt/homebrew/var/log/postgresql@16.log ```

Beyond the application-level log, launchd itself records exit codes and reasons in the system log. Query it with `log show`:

``` log show --predicate 'subsystem == "com.apple.launchd"' --last 10m ```

For a specific label:

``` log show --predicate 'eventMessage contains "homebrew.mxcl.redis"' --last 30m --info ```

If the service crashed rather than exited cleanly, the crash report appears in `~/Library/Logs/DiagnosticReports/` or `/Library/Logs/DiagnosticReports/` for root-level daemons. The crash report includes the faulting instruction, register state, and the full backtrace - far more useful than the exit code alone.

One non-obvious failure mode we have hit repeatedly: a Homebrew formula upgrade changes the binary path but the old plist still references the previous version. `brew services restart` does not always regenerate the plist after an upgrade. The fix is to stop the service, uninstall the plist manually, then start it again so Homebrew writes a fresh plist:

``` brew services stop redis rm ~/Library/LaunchAgents/homebrew.mxcl.redis.plist brew services start redis ```

# Check exit code and last run time for any service
brew services info redis --json | jq '.[] | {name, status, exit_code, running}'

# Read stderr log path from plist
/usr/libexec/PlistBuddy -c 'Print :StandardErrorPath' \
  ~/Library/LaunchAgents/homebrew.mxcl.redis.plist
// advertisement

Working with sudo-level system daemons

Services that need to start at boot before user login, or that must bind to ports below 1024 without the macOS port-forwarding workaround, require installation as LaunchDaemons under `/Library/LaunchDaemons/`.

Install them with `sudo`:

``` sudo brew services start nginx ```

This places the plist in `/Library/LaunchDaemons/homebrew.mxcl.nginx.plist` and loads it as root via `launchctl bootstrap system`. To verify:

``` sudo launchctl list | grep homebrew ```

The output columns from `launchctl list` are PID, last exit status, and label. A dash in the PID column means the process is not currently running. Exit status 0 means it stopped cleanly; any other value indicates an error.

One operational issue specific to macOS: System Integrity Protection (SIP) blocks modifications to `/System/Library/LaunchDaemons/` even as root. Homebrew daemons go to `/Library/LaunchDaemons/` which is not SIP-protected, so this is rarely a problem in practice. It becomes relevant only if you are trying to modify Apple-owned service definitions, which you should not be doing on a production machine anyway.

For teams managing fleet Macs with MDM tools like Jamf or Mosyle, push the LaunchDaemon plist via a configuration profile rather than running `sudo brew services start` on each machine. The plist format is identical to what Homebrew generates - you can pull it from one correctly configured machine and deploy it org-wide:

``` sudo cp /Library/LaunchDaemons/homebrew.mxcl.nginx.plist /tmp/ # deploy /tmp/homebrew.mxcl.nginx.plist via MDM ```

# Verify a system-level daemon is loaded
sudo launchctl print system/homebrew.mxcl.nginx

# Bootstrap manually from plist (equivalent to brew services start with sudo)
sudo launchctl bootstrap system /Library/LaunchDaemons/homebrew.mxcl.nginx.plist

# Bootout (unload) without deleting the plist
sudo launchctl bootout system/homebrew.mxcl.nginx

Editing service configuration without breaking plist ownership

Homebrew regenerates the plist from the formula's service block every time you run `brew services start` or `brew services restart`. Any manual edits to the plist file are overwritten. This is by design, and it means the correct place to customize service behavior is through the formula's environment or through a wrapper script.

For environment variable injection, set them in your shell profile or in a `.env` file that your wrapper script sources. For PostgreSQL, the data directory is set via the `PGDATA` environment variable. If you need a non-default data directory:

``` export PGDATA=/Volumes/FastDisk/postgresql/data brew services restart postgresql@16 ```

Brew services will pass environment variables present in the plist's `EnvironmentVariables` dict. To add custom variables persistently without losing them on plist regeneration, use the formula's `service` block if you maintain a custom tap, or use `launchctl setenv` for running services:

``` sudo launchctl setenv MY_VAR my_value ```

Note that `launchctl setenv` only survives until the next reboot. For a persistent global environment variable, add a LaunchAgent plist that sets it at login:

```xml Label com.myunix.env ProgramArguments /bin/launchctl setenv MY_VAR my_value RunAtLoad ```

Save this to `~/Library/LaunchAgents/com.myunix.env.plist` and load it with `launchctl load`.

# Dump the current environment a service sees (requires process to be running)
brew services info postgresql@16 --json | jq '.[] | .pid' | xargs -I{} ps eww -p {} | tr ' ' '\n' | grep '='

Automating service management in CI and DevOps pipelines

On macOS CI runners - GitHub Actions self-hosted, Buildkite agents, or Xcode Cloud-adjacent setups - Homebrew services need careful handling because the runner user session may not persist between jobs, and services may need to start before the test suite and stop cleanly afterward.

The standard pattern for GitHub Actions self-hosted macOS runners:

```yaml steps: - name: Start PostgreSQL run: | brew services start postgresql@16 # Wait for the socket to appear until pg_isready -q; do sleep 1; done - name: Run tests run: make test - name: Stop services if: always() run: brew services stop --all ```

The `if: always()` on the stop step ensures cleanup runs even when tests fail - critical on shared self-hosted runners where a lingering PostgreSQL instance will conflict with the next job.

For teams using AI-driven pipeline orchestration, tools like taskbotshub.ai can manage the sequencing of service startup and health-check steps across heterogeneous macOS and Linux runners, reducing the manual YAML maintenance that accumulates as pipelines grow.

On ephemeral runners (each job gets a fresh VM), you can skip `brew services stop` since the instance is discarded. But you still need the startup health check - `brew services start` returns before the service is ready to accept connections, and tests that run immediately after will hit connection refused errors.

For dnsmasq specifically, which many macOS dev setups use for `*.test` domain resolution, the service must run as root and the resolver file must be in place before the service starts:

``` echo 'address=/.test/127.0.0.1' > $(brew --prefix)/etc/dnsmasq.conf sudo mkdir -p /etc/resolver echo 'nameserver 127.0.0.1' | sudo tee /etc/resolver/test sudo brew services start dnsmasq ```

This pattern is repeatable in a setup script, idempotent across runner refreshes, and does not require manual intervention when the runner OS is updated.

#!/usr/bin/env bash
# Reliable service startup with health check
start_service_with_check() {
  local service="$1"
  local check_cmd="$2"
  local max_wait=30

  brew services start "$service"

  local elapsed=0
  until eval "$check_cmd" 2>/dev/null; do
    sleep 1
    elapsed=$((elapsed + 1))
    if [ "$elapsed" -ge "$max_wait" ]; then
      echo "ERROR: $service did not become ready within ${max_wait}s"
      brew services info "$service"
      exit 1
    fi
  done
  echo "$service ready after ${elapsed}s"
}

# Usage
start_service_with_check postgresql@16 'pg_isready -q'
start_service_with_check redis 'redis-cli ping | grep -q PONG'
// advertisement

Debugging common failure patterns

Four failure patterns account for the majority of brew services problems on macOS.

**Port already in use.** A previous service instance is still running outside launchd's knowledge - often left over from a direct binary invocation or a Docker port binding. Check with:

``` lsof -i :5432 ```

Kill the conflicting process and start the service again.

**Wrong architecture.** On Apple Silicon Macs, Homebrew installs to `/opt/homebrew/`. If you have a Rosetta terminal session or a mixed environment, you may have two Homebrew installations at `/opt/homebrew/` (ARM) and `/usr/local/` (x86). Services started from the wrong prefix will fail silently or conflict. Verify:

``` which brew brew --prefix file $(brew --prefix)/bin/brew ```

The output of `file` should show `arm64` on M-series Macs. If it shows `x86_64`, you are in a Rosetta session.

**Stale socket files.** PostgreSQL and some other services create Unix socket files at startup. If the service crashed without cleaning up, the socket file remains and the next startup attempt fails. For PostgreSQL:

``` rm /tmp/.s.PGSQL.5432 rm /tmp/.s.PGSQL.5432.lock brew services start postgresql@16 ```

**SIP-blocked plist location.** If a formula tries to write a plist to a SIP-protected path, `brew services start` will appear to succeed but the service will never load. Check `launchctl list | grep homebrew` and verify the label appears. If it does not, examine the plist destination:

``` brew services info --json | jq '.[] | .file' ```

If the path starts with `/System/`, the formula has a bug in its service block. File an issue upstream or override with a local formula edit via `brew edit `.

# Check all homebrew services registered with launchd (user session)
launchctl list | grep homebrew

# Check system-level (root) services
sudo launchctl list | grep homebrew

# Force reload a specific plist after manual edit
launchctl unload ~/Library/LaunchAgents/homebrew.mxcl.redis.plist
launchctl load ~/Library/LaunchAgents/homebrew.mxcl.redis.plist

Managing services across multiple macOS versions

macOS major version upgrades frequently break Homebrew services because the upgrade process migrates launchd plists but does not guarantee that Homebrew-installed binaries at their pre-upgrade paths still resolve correctly. After upgrading from Sonoma 14 to Sequoia 15, we found that three of eleven services showed 'error' status due to Xcode Command Line Tools being reset.

The first thing to run after a major OS upgrade:

``` brew update && brew upgrade brew services stop --all brew services start --all ```

If services still show errors after upgrade, the nuclear option is to remove all Homebrew-managed plists and re-register them:

``` brew services stop --all rm ~/Library/LaunchAgents/homebrew.mxcl.*.plist brew services start postgresql@16 redis nginx dnsmasq ```

For teams running a shared development environment documented as a project, keeping the exact service list in a setup script (rather than tribal knowledge) prevents the post-upgrade guessing game. If you are naming that project or setting up a domain for internal tooling documentation, registering a clean name through a service like nicename.me early in the project lifecycle avoids the naming drift that happens when team wikis and scripts accumulate different shorthand references for the same stack.

For Homebrew itself, pin the Homebrew version used in your setup scripts by setting `HOMEBREW_NO_AUTO_UPDATE=1` and committing the `Brewfile.lock.json` to version control. This makes service behavior reproducible across machines and OS versions:

``` export HOMEBREW_NO_AUTO_UPDATE=1 brew bundle install --file=Brewfile brew services start --all ```

# Generate a Brewfile from current install including service state
brew bundle dump --force

# Check which formulae have service definitions
brew list --formula | while read f; do
  if brew info "$f" --json | jq -e '.[].service' > /dev/null 2>&1; then
    echo "$f has service definition"
  fi
done