Processes, signals and systemd
What is running, how to stop it properly, and how to keep your app alive with systemd.
In 30 seconds
Every running program is a process with an ID, an owner and a parent. You inspect processes with ps and top, and control them with signals. On modern Linux, systemd starts services at boot, restarts them when they crash and collects their logs. Writing a small unit file is the cleanest way to keep a Node or Python app running on a server.
Why I use it
- I keep long-running work alive on my servers with process managers: supervisor for Laravel queue workers and pm2 for Node apps, both of which run on top of systemd.
- When a server is slow, the first thing I check is which process is using CPU or memory, and that starts with top and ps.
- Knowing systemd and journalctl means I can see why a service failed to start instead of guessing.
How it works
01Processes and PIDs
A process is a running instance of a program. The kernel gives each one a process ID (PID), and each process has a parent (its PPID). On a systemd machine, PID 1 is systemd, the ancestor of everything else. A process runs as a specific user, has its own memory, open files and environment variables, and ends with an exit code: 0 means success, anything else means some kind of failure. ps shows a snapshot of processes, top and htop show a live, sorted view. In top, the load average shows how many processes want CPU on average over 1, 5 and 15 minutes; compare it with the number of CPU cores (nproc).
ps aux | head # all processes, with user, CPU, memory
ps aux --sort=-%mem | head -n 10 # biggest memory users
ps -ef --forest | less # parent/child tree
pgrep -a node # find processes by name
top # live view (P = sort CPU, M = memory, q = quit)
htop # friendlier top (apt install htop)
uptime; nproc # load average vs. number of cores
02Signals and kill
Signals are short messages the kernel delivers to a process. Despite its name, kill just sends a signal. SIGTERM (15, the default) politely asks a process to stop: a good app finishes in-flight requests, closes database connections and exits. SIGKILL (9) cannot be caught: the kernel removes the process immediately, without cleanup, which can leave half-written files or locks behind, so use it only when TERM does not work. SIGINT (2) is what Ctrl+C sends. SIGHUP (1) traditionally means 'reload your config', and many daemons such as nginx honor it. A well-written app handles SIGTERM, because that is what systemd, Docker and Kubernetes send on every deploy or shutdown.
kill 4242 # SIGTERM: please stop
kill -HUP 4242 # reload config (if the app supports it)
kill -9 4242 # SIGKILL: last resort, no cleanup
pkill -f "node server.js" # by command line
# In Node: shut down gracefully on SIGTERM
# process.on("SIGTERM", () => server.close(() => process.exit(0)));
03Foreground, background and staying alive
A command you start in a terminal runs in the foreground and is tied to that session. Ctrl+Z pauses it, bg resumes it in the background, fg brings it back, and adding & at the end starts it in the background from the start. When you close the SSH session, the shell sends SIGHUP to its jobs and they usually die. nohup or a terminal multiplexer like tmux keep a process running after you log out, which is fine for a one-off migration or a long copy. But none of these restart a crashed process or start it after a reboot. For anything that must stay up, use a service manager.
long-task & # start in background
jobs # list background jobs
fg %1 # bring job 1 back
nohup ./import.sh > import.log 2>&1 & # survive logout
tmux new -s work # detach with Ctrl+B then D
tmux attach -t work # reattach later
04systemd units and systemctl
systemd is the init system and service manager on almost every current distro, Ubuntu, Debian and RHEL included. It manages units: a .service runs a process, a .timer triggers a service on a schedule, a .socket starts a service on demand, and a .target groups units. Packages install their units in /usr/lib/systemd/system (or /lib/systemd/system on Debian); your own units and overrides go in /etc/systemd/system, which takes priority. systemctl is the command to control them. Prefer reload over restart when a service supports it, because reload applies new config without dropping connections. After you create or edit a unit file, run systemctl daemon-reload so systemd reads it again.
systemctl status nginx # state, PID, last log lines
sudo systemctl restart nginx # stop + start
sudo systemctl reload nginx # re-read config, keep connections
sudo systemctl enable --now myapp # start now and at every boot
sudo systemctl disable myapp # do not start at boot
systemctl list-units --type=service --state=failed
systemctl cat nginx # show the unit file in use
sudo systemctl edit nginx # safe override in /etc/systemd/system
sudo systemctl daemon-reload # after editing unit files
05Writing a unit for a Node app
A unit file is a short INI-style config. [Unit] describes the service and its ordering, [Service] says how to run it, and [Install] says when to start it at boot. The example below runs a Node app as its own unprivileged user, loads secrets from a protected env file, restarts it if it crashes and adds some cheap sandboxing. Type=simple fits apps that stay in the foreground, which is what you want: do not let the app daemonize itself. Keep the app listening on localhost and put nginx in front for TLS. Save this as /etc/systemd/system/myapp.service, then run daemon-reload and enable --now.
[Unit]
Description=My Node app
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=myapp
WorkingDirectory=/srv/myapp
EnvironmentFile=/etc/myapp/env
ExecStart=/usr/bin/node server.js
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
ProtectSystem=full
PrivateTmp=true
[Install]
WantedBy=multi-user.target
06journalctl: reading service logs
systemd captures everything a service prints to stdout and stderr in the journal, with timestamps and metadata, so your app can simply log to the console. journalctl reads it. Filter by unit with -u, follow live with -f, limit by time with --since, and show only errors with -p err. When a service will not start, systemctl status shows the last few lines, and journalctl -u name -b shows everything since the last boot. The journal is stored on disk under /var/log/journal on most distros, and its size can be capped, which is covered in the logs lesson.
journalctl -u myapp -f # follow live
journalctl -u myapp --since "1 hour ago"
journalctl -u myapp -b -p err # errors since boot
journalctl -u nginx -n 50 --no-pager # last 50 lines
journalctl -k # kernel messages (e.g. OOM kills)
07Timers vs cron
cron is the classic scheduler: one line per job in a crontab, simple and available everywhere. systemd timers do the same job with more features. A timer unit triggers a matching service unit, so the job gets logs in the journal, resource limits, dependencies, and Persistent=true runs a missed job after the machine was off. You can list all timers and their next run with systemctl list-timers. For a single quick job, cron is fine and well understood. For important jobs where you want logs, retries and no overlapping runs, timers are the better tool. Many distro maintenance tasks, such as apt updates and log rotation, already run as timers.
# /etc/systemd/system/backup.timer (runs backup.service)
[Unit]
Description=Nightly backup
[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
RandomizedDelaySec=10m
[Install]
WantedBy=timers.target
# Enable with: sudo systemctl enable --now backup.timer
# Check with: systemctl list-timers
Use it when
- Running any long-lived app (Node, Python, Go, workers) on a VPS without containers.
- Diagnosing high CPU, memory leaks or a hung process.
- Scheduling recurring jobs that need logs, limits or catch-up after downtime (timers).
- Graceful restarts during deploys: send SIGTERM, let the app drain, start the new version.
Skip it when
- Inside Docker containers there is usually no systemd; the container runtime or Compose restarts the process.
- Do not keep production apps alive with nohup, screen or tmux; they do not restart on crash or boot.
- Do not use kill -9 as your normal stop command; it skips all cleanup.
Alternatives
- pm2
Popular Node process manager with clustering and log handling; itself started by a systemd unit.
- supervisor
Simple, mature manager often used for Laravel queue workers and multiple worker processes.
- Docker Compose
Restart policies keep containers running; the Docker daemon itself is a systemd service.
- cron
Simplest scheduler for small jobs where journal logging and dependencies do not matter.
Best practices
- Run each service as its own unprivileged user, never as root.
- Handle SIGTERM in your app so restarts and deploys do not drop requests or corrupt data.
- Use Restart=on-failure with a RestartSec so a crash-looping app does not hammer the system.
- Put secrets in an EnvironmentFile with mode 600, not inline in the unit.
- Use systemctl edit for overrides instead of changing package unit files.
- Log to stdout/stderr and let journald collect it; cap the journal size.
- Check systemctl --failed after reboots and updates.
Common mistakes
- Editing a unit file and forgetting systemctl daemon-reload, so the old version keeps running.
- Starting an app with node server.js & over SSH and wondering why it disappears after logout or reboot.
- Using kill -9 by default and leaving stale lock files or half-written data.
- Letting the app daemonize itself while the unit says Type=simple, so systemd thinks it exited.
- Mistaking load average for CPU percentage without comparing it to the number of cores.
Deep dive
08Process states, zombies and the real meaning of load
The STAT column of ps tells you what a process is doing. R is running or ready to run, S is sleeping until something happens (most processes, most of the time), D is uninterruptible sleep, usually waiting on disk or a network filesystem, T is stopped and Z is a zombie. A zombie has already exited; it only keeps its entry so the parent can read the exit code with wait. Zombies use no CPU or memory, but many of them mean a buggy parent that never reaps children; killing the zombie does nothing, fixing or restarting the parent does. If a parent dies first, its children are adopted by PID 1 or a subreaper. On Linux the load average counts both R and D tasks, so high load with idle CPU usually means processes stuck in D on slow storage, not too much computation.
ps -eo pid,ppid,stat,wchan:20,cmd | awk '$3 ~ /D|Z/' # stuck or zombie
ps -o ppid= -p 12345 # parent of a zombie
cat /proc/loadavg # 1/5/15 min load, running/total tasks
cat /proc/pressure/cpu /proc/pressure/io # PSI: time spent stalled
cat /proc/12345/status | grep -E 'State|VmRSS|Threads'
ls -l /proc/12345/fd | wc -l # open file descriptors
09cgroups v2: putting limits on services
systemd places every service in its own control group (cgroup), a kernel container for accounting and limits. Current Ubuntu, Debian and RHEL releases use the unified cgroup v2 hierarchy, which systemd exposes as simple unit settings. MemoryHigh throttles and reclaims memory when a service goes above a soft limit, MemoryMax is a hard cap that triggers the OOM killer inside that service only, so a leaking worker dies instead of the database next to it. CPUQuota caps CPU time, CPUWeight sets relative priority under contention, IOWeight does the same for disk, and TasksMax limits the number of processes and threads, which stops a fork bomb. systemd-cgtop shows live usage per service. You can change limits on a running service with systemctl set-property, which also writes a persistent drop-in.
# /etc/systemd/system/worker.service.d/limits.conf
[Service]
MemoryHigh=400M
MemoryMax=512M
CPUQuota=150%
TasksMax=256
IOWeight=50
# Apply without editing files:
# sudo systemctl set-property worker.service MemoryMax=512M
# Watch usage live:
# systemd-cgtop
10Dependencies, start-up types and restart policy
Two separate ideas are often confused. Wants= and Requires= say which units must be started together; After= and Before= only set the order. Requires without After starts both at once, and Requires means your unit is stopped if the dependency is stopped too, so Wants plus After is the gentle default. Type= tells systemd when start-up is finished: simple considers the service started immediately after fork, exec waits until the binary was actually executed, so a wrong path fails the start, and notify waits for the app to report readiness through sd_notify. Restart=on-failure is limited by StartLimitIntervalSec and StartLimitBurst in [Unit]; after too many restarts in that window systemd gives up with 'start request repeated too quickly'. On stop, systemd sends KillSignal (SIGTERM), waits TimeoutStopSec, then sends SIGKILL to the whole cgroup, so no child process escapes.
[Unit]
Description=Queue worker
Wants=redis-server.service
After=network-online.target redis-server.service
StartLimitIntervalSec=300
StartLimitBurst=5
[Service]
Type=exec
User=worker
ExecStart=/usr/bin/node /srv/worker/index.js
Restart=on-failure
RestartSec=5
TimeoutStopSec=60
KillMode=mixed
11Hardening a service and measuring the result
systemd can sandbox a service with a few lines, often more effectively than a container. ProtectSystem=strict mounts the whole filesystem read-only for the service, and ReadWritePaths= opens only what it must write. ProtectHome=true hides /home, PrivateTmp gives it its own /tmp, PrivateDevices hides hardware, NoNewPrivileges blocks setuid escalation, and RestrictAddressFamilies limits which socket types it may open. An empty CapabilityBoundingSet= drops every capability. systemd-analyze security name prints an exposure score with a list of options you have not set, which makes hardening incremental and measurable. Start with the cheap, safe options, restart, check the journal for errors, and add more. Boot and start-up time are measurable too: systemd-analyze blame and critical-chain show which units slow the boot.
# /etc/systemd/system/myapp.service.d/hardening.conf
[Service]
ProtectSystem=strict
ReadWritePaths=/var/lib/myapp /var/log/myapp
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
NoNewPrivileges=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
CapabilityBoundingSet=
# Check the score: systemd-analyze security myapp.service
Real-world scenarios
A service is stuck in a crash loop
Problem
After a deploy, systemctl status shows the app as failed with 'start request repeated too quickly'. Restarting by hand fails within seconds every time.
How to solve it
The start limit was hit, so systemd stopped trying. Read the cause first: journalctl -u myapp -b -n 100 shows the real error, often a missing environment variable, a wrong path after the release switch, a port still held by an old process (ss -ltnp) or a permission problem. Run the ExecStart line by hand as the service user with sudo -u myapp to reproduce it. Fix the cause, run daemon-reload if the unit changed, then systemctl reset-failed myapp and start it. Afterwards, make the deploy fail fast instead: a health check after restart and an automatic rollback of the symlink if the service is not active within a timeout.
A leaking worker takes the database down
Problem
Every few days the server becomes unresponsive and the OOM killer kills MySQL. Memory graphs show a queue worker growing slowly until RAM runs out.
How to solve it
Contain it first, then fix it. Give the worker a cgroup limit with MemoryHigh and MemoryMax in a drop-in, so the OOM killer acts inside the worker's cgroup and Restart=on-failure brings it back, while the database is untouched. Protect the database further with OOMScoreAdjust=-500 in its unit. Most queue frameworks can also exit after a number of jobs or a memory threshold, letting the process manager restart a fresh worker. Then find the leak: compare memory per job type, take heap snapshots in Node, look for growing caches or unclosed connections. Check journalctl -k for 'Memory cgroup out of memory' lines to confirm the limit is working.
Deploys drop in-flight requests
Problem
Each restart of a Node API during deploys produces a burst of 502 errors in nginx and a few interrupted background jobs.
How to solve it
The app exits immediately on SIGTERM, or does not listen for it and is killed. Handle SIGTERM in code: stop accepting new connections with server.close, let open requests finish, finish or release the current job, close database pools, then exit with 0. Set TimeoutStopSec in the unit slightly longer than the longest normal request so systemd does not escalate to SIGKILL. For zero-downtime, run two instances on different ports or sockets behind an nginx upstream and restart them one at a time, or use a process manager with graceful reload. Add proxy_next_upstream in nginx so a request to a stopping instance is retried on the other one.
Cheat sheet
- Find zombie and D-state processes
ps -eo pid,ppid,stat,cmd | awk '$3 ~ /Z|D/'- Live CPU and memory per service
systemd-cgtop- Set a memory cap on a running service
sudo systemctl set-property myapp.service MemoryMax=512M- All properties of a unit
systemctl show myapp -p MainPID,MemoryCurrent,NRestarts- Clear a failed state after fixing
sudo systemctl reset-failed myapp- Check a unit file for errors
systemd-analyze verify /etc/systemd/system/myapp.service- Hardening score of a service
systemd-analyze security myapp.service- What slows down boot
systemd-analyze blame; systemd-analyze critical-chain- Run a one-off command as a transient unit
sudo systemd-run --unit=import -p MemoryMax=1G /srv/app/import.sh- Pressure stall info (CPU, memory, I/O)
cat /proc/pressure/{cpu,memory,io}
In my real projects
- I run Laravel queue workers under supervisor and Node apps under pm2 on my servers, with cron triggering Laravel's scheduler.
- On the POS SaaS server, long-running services and BullMQ workers run in Docker Compose, with health checks to see that they are alive.
- I use the service logs to diagnose production issues before changing anything, read-only first.
Questions & answers
Q1What is the difference between SIGTERM and SIGKILL?
SIGTERM is a request to terminate. The process can catch it and shut down gracefully: stop accepting new work, finish in-flight requests, flush buffers, close database connections and exit. It is the default signal of kill and what systemd, Docker and Kubernetes send first. SIGKILL cannot be caught, blocked or ignored; the kernel ends the process immediately, so no cleanup happens and you may get corrupted files, stale locks or lost jobs. The right procedure is TERM, wait a reasonable timeout, then KILL only if the process is still alive. That is exactly what systemd does with TimeoutStopSec.
Q2How would you keep a Node.js app running on a Linux server?
I would not start it by hand over SSH. The simplest robust option is a systemd unit: run it as a dedicated user, set WorkingDirectory and an EnvironmentFile for secrets, ExecStart with the node binary, Restart=on-failure, and enable it so it starts at boot. Logs go to the journal. Alternatives are pm2, which adds clustering and its own log handling and is started at boot through a systemd unit generated by pm2 startup, or running it in a container with a restart policy. In all cases I bind the app to localhost, put nginx in front for TLS and handle SIGTERM in code for zero-surprise restarts.
Q3A server is slow. How do you find out why?
I start with uptime and compare the load average to nproc. Then top or htop, sorted by CPU and then memory, to find the heavy process. free -h shows whether memory is exhausted and swap is in use, and journalctl -k or dmesg shows whether the OOM killer has been killing processes. If CPU is low but load is high, processes are probably waiting on disk, so I check iostat or the wa value in top and df -h for a full disk. Finally I look at the application side: slow queries, a stuck queue worker, or a traffic spike visible in the nginx access log.
Q4What is the difference between systemctl restart and reload?
restart stops the service and starts it again, so there is a brief moment with no process and existing connections are dropped. reload asks the running process to re-read its configuration, usually by sending SIGHUP or running a configured ExecReload command, and keeps serving. nginx, for example, starts new worker processes with the new config and lets old ones finish their requests, so a reload is invisible to users. Not every service supports reload; systemctl reload-or-restart handles both cases. Before reloading nginx I always run nginx -t, because a broken config fails the reload and you want to catch that first.
Q5systemd timers or cron: which would you choose?
For a small, simple job, cron is perfectly fine and everyone knows its syntax. For important jobs I prefer systemd timers. The job runs as a normal service, so its output goes to the journal with timestamps, it can have resource limits and a dedicated user, it will not start a second copy while the first is still running, and Persistent=true catches up on runs missed while the server was down. systemctl list-timers also shows when each job last ran and runs next, which cron cannot. The cost is two small files instead of one line.
Q6Where do you look when a systemd service fails to start?
systemctl status name shows the state, the exit code and the last log lines, which is often enough. For more detail, journalctl -u name -b shows all its output since boot, and adding -p err filters to errors. Common causes are a wrong path in ExecStart, a missing environment variable, a port already in use (check with ss -ltnp), file permissions for the service user, or a syntax error in the unit itself, which systemd-analyze verify can catch. If I edited the unit, I make sure I ran daemon-reload. Once fixed, systemctl reset-failed clears the failed state.
Senior-level questions
S1Why should a container or service manager, not your app, be PID 1, and what goes wrong otherwise?
PID 1 has special duties. The kernel does not apply default signal handlers to it, so a SIGTERM that the app does not explicitly handle is simply ignored, and the container waits for the timeout before being killed. PID 1 must also reap orphaned children; an app that spawns subprocesses, such as a shell wrapper or headless browser, accumulates zombies if it never calls wait. On a host, systemd is PID 1 and handles both. In containers, either use a tiny init such as tini (docker run --init) or make sure the app handles signals and does not spawn unmanaged children. Also start the app with exec form in the Dockerfile or with exec in the entry script, so it receives signals instead of an intermediate shell.
S2systemd unit, pm2, supervisor or containers: how do you choose a process manager?
All of them restart processes; the differences are scope and dependencies. A plain systemd unit needs nothing extra, gives cgroup limits, sandboxing and journal logs, and is the most robust choice for one or two services per host. pm2 adds Node-specific comfort, such as cluster mode across cores, zero-downtime reloads and a deploy workflow, at the cost of another daemon with its own log files. supervisor is simple and language-neutral, popular for running several queue worker processes, but has no cgroup limits of its own. Containers bring reproducible images and isolation, which pays off with many services or teams. Whichever you choose, avoid stacking managers without reason, and be clear which layer owns restarts, logs and limits.
S3How would you limit the blast radius of a memory leak on a shared server?
Assume something will leak and decide in advance who should die. Put each service in its own cgroup with MemoryHigh as a soft limit, so the kernel reclaims and slows the service first, and MemoryMax as a hard limit, so the OOM killer acts inside that cgroup only. Lower the OOM score of critical services such as the database with OOMScoreAdjust. Let the restart policy recover the leaking service and alert on NRestarts or OOM events, so the leak is visible instead of silently healed. Keep some swap to absorb spikes, and consider systemd-oomd, which can act earlier on memory pressure. The trade-off is that limits set too low cause restarts under legitimate peaks, so base them on observed usage plus headroom.
Glossary
- Zombie process
- A process that has exited but whose parent has not yet collected its exit status with wait.
- Uninterruptible sleep (D)
- A state where a process waits for I/O and cannot be interrupted, even by SIGKILL, until the I/O completes.
- Subreaper
- A process that adopts orphaned descendants instead of PID 1, as systemd user managers and container init processes do.
- cgroup
- Control group: a kernel grouping of processes used to account for and limit CPU, memory, I/O and process count.
- Drop-in
- A small .conf file in a unit's .d directory that overrides selected settings without replacing the whole unit.
- sd_notify
- A protocol an app uses to tell systemd it is ready or still alive, used with Type=notify and WatchdogSec.
- Start limit
- systemd's cap on restarts within a time window. Once hit, the unit stays failed until reset.
- PSI
- Pressure Stall Information: kernel metrics showing how much time tasks wait for CPU, memory or I/O.
- Transient unit
- A unit created on the fly with systemd-run, with limits and logging, that disappears when it finishes.
Keep learning
Have a product in mind?Let's build it.
Tell me about your idea, your product or your team. I usually reply within one working day.