Bash stores the process ID of the most recently started asynchronous command in $!. That makes the minimal case straightforward:
/usr/local/bin/program &
pid=$!
printf '%s\n' "$pid" > /run/example-worker.pid Capture $! immediately. Starting another background command first overwrites it. The value identifies the asynchronous job created by the current shell; if the command forks and exits, it does not magically change to the final daemon process. Pipelines and subshell wrappers can likewise make it identify a process other than the application a caller expects.
The short snippet is enough for controlled temporary work, but it is not a complete service launcher. Concurrent starts can overwrite the file, readers can observe a partially written value, the file can remain after a crash, and a recycled PID can later refer to an unrelated process.
A PID File Is Not a Lock
A PID file records an identifier for status tools and cooperating scripts. Its existence does not prove that the process still runs, and kill -0 proves only that a PID currently exists and is signalable. It does not prove process identity.
Use an operating-system lock to prevent duplicate instances. On Linux, flock attaches the lock to an open file description and releases it when the last associated file descriptor is closed. This avoids stale-lock cleanup: a leftover lock-file pathname is harmless because the kernel lock, not the pathname, represents ownership.
A Safe Foreground Wrapper
The following Bash wrapper launches one foreground-style worker, publishes its child PID atomically, forwards common termination signals, waits for the child, and returns its exit status. It requires a private runtime directory owned by the service account. For a login session, XDG_RUNTIME_DIR is often suitable; a system service can use a directory below /run created with the correct owner before launch.
#!/usr/bin/env bash
set -u
set -o pipefail
if (( $# == 0 )); then
printf 'Usage: %s command [argument ...]\n' "$0" >&2
exit 64
fi
runtime_directory=${RUNTIME_DIRECTORY:-${XDG_RUNTIME_DIR:-}}
if [[ -z $runtime_directory || ! -d $runtime_directory ]]; then
printf 'Set RUNTIME_DIRECTORY to an existing private directory.\n' >&2
exit 73
fi
pid_file="$runtime_directory/example-worker.pid"
lock_file="$runtime_directory/example-worker.lock"
exec 9>"$lock_file"
if ! flock -n 9; then
printf 'example-worker is already running.\n' >&2
exit 75
fi
"$@" &
child_pid=$!
temporary_file="$pid_file.$$.tmp"
wait_interrupted=false
cleanup() {
local recorded_pid=
if [[ -r $pid_file ]]; then
IFS= read -r recorded_pid < "$pid_file" || true
if [[ $recorded_pid == "$child_pid" ]]; then
rm -f -- "$pid_file"
fi
fi
rm -f -- "$temporary_file"
}
forward_signal() {
wait_interrupted=true
kill -s "$1" -- "$child_pid" 2>/dev/null || true
}
wait_for_child() {
local child_status
while true; do
wait "$child_pid"
child_status=$?
if [[ $wait_interrupted == false ]]; then
return "$child_status"
fi
wait_interrupted=false
done
}
trap cleanup EXIT
trap 'forward_signal HUP' HUP
trap 'forward_signal INT' INT
trap 'forward_signal TERM' TERM
umask 077
printf '%s\n' "$child_pid" > "$temporary_file"
mv -f -- "$temporary_file" "$pid_file"
wait_for_child
exit $? Run it with a command that remains in the foreground:
install -d -m 0700 "$HOME/.run"
RUNTIME_DIRECTORY="$HOME/.run" ./run-with-pid /usr/local/bin/program --foreground The lock is acquired before the child starts. The PID is first written to a same-directory temporary file and then moved over the public pathname, so readers see either the old complete file or the new complete file. The restrictive umask keeps other users from replacing the temporary contents through ordinary file access.
The cleanup function removes the PID file only if it still contains this wrapper's child PID. That check prevents an older exiting wrapper from deleting a file already replaced by a newer owner. The lock file itself is intentionally retained; deleting a lock pathname while another process still has it open can create two independently locked files.
The loop around wait matters because Bash runs a trapped signal after an interrupted wait. The handler marks that interruption, forwards the signal, and makes the wrapper wait again for the child's final status. This keeps the PID file and lock in place during a graceful shutdown without another process-identity check.
Read a PID File Defensively
A status script should validate the complete value before using it:
pid_file=/run/example-worker/example-worker.pid
if IFS= read -r pid < "$pid_file" && [[ $pid =~ ^[1-9][0-9]*$ ]]; then
if kill -0 -- "$pid" 2>/dev/null; then
printf 'A process with PID %s exists.\n' "$pid"
else
printf 'The PID file is stale or the process is not signalable.\n' >&2
fi
else
printf 'The PID file is missing or invalid.\n' >&2
fi Do not turn that status check into an unconditional kill. PID reuse means a stale numeric value can name an unrelated process. A cooperating lock or a service manager is the authority for ownership. Linux-specific checks under /proc can add diagnostics, but a pathname such as /proc/$pid/exe still does not remove the race between checking a PID and acting on it.
Prefer a Service Manager for Daemons
A shell that backgrounds a process and then exits does not provide restart policy, ordered startup, resource limits, logging, or reliable signal delivery. A cron job that periodically inspects a PID file also has detection delays and PID-reuse races. On a systemd machine, keep the application in the foreground and let the service manager own its lifecycle:
[Unit]
Description=Example worker
After=network.target
[Service]
Type=exec
ExecStart=/usr/local/bin/program --foreground
Restart=on-failure
RestartSec=5s
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=strict
[Install]
WantedBy=multi-user.target With Type=exec, systemd tracks the launched process directly and reports an execution failure during startup. No PIDFile= directive is needed. Standard output and standard error go to the journal by default, while Restart=on-failure handles crashes without restarting after an intentional stop.
Use the Bash wrapper when another tool genuinely requires a PID file or for a small, controlled launcher. Use a service manager when the requirement is actually supervision.
References
- [Bash]GNU Bash Reference Manual: Special Parameters.
- [Wait]GNU Bash Reference Manual: Job Control Builtins.
- [flock]util-linux flock manual page.
- [systemd.service]systemd.service manual.