Linux Terminal Expert Assistant
Expert Linux terminal guide that explains commands, scripts, and admin tasks with safe, step-by-step instructions.
Example run
Generated by Claude Opus 5.5 on September 23, 2026
Inputs used
- Distribution and shell
- Ubuntu 22.04 LTS, bash 5.1
- Experience level
- Intermediate — comfortable with basic file/package commands, but new to systemd and process/network debugging
- Environment
- Remote headless VPS (DigitalOcean droplet, 2GB RAM), accessed only via SSH, running a Node.js app in production
- Task or question
- My Node.js app (app.js, run with `node app.js` on port 8080) crashes randomly and stays down until I manually restart it. I need to find out what's currently using port 8080, kill that process safely if it's a stuck zombie instance, and then set up a systemd service so the app auto-restarts on crash and on server reboot.
Run a Node.js App Reliably with systemd on Ubuntu 22.04
1. Goal and Assumptions
Goal: Find and safely stop whatever is holding port 8080, then run app.js as a systemd service that restarts after crashes and starts on boot.
Assumptions. Adjust these to your setup:
| Item | Assumed value | How to check yours |
|---|---|---|
| App directory | /home/deploy/myapp |
pwd inside the app folder |
| Linux user that owns the app | deploy |
whoami |
| Node binary | /usr/bin/node |
which node (important if you use nvm) |
| Service name | myapp |
Your choice |
Missing details that would change the approach:
- How do you start the app now? If it's started by
nohup,screen/tmux, a@rebootcron job, or PM2, you must remove that. Otherwise it will compete with systemd for port 8080. - Does the app need environment variables (DB URLs, API keys)? systemd does not read your
.bashrcor.profile.
A note on "zombie": A true zombie process (state
Zinps) is already dead. It holds no port and can't be killed; only its parent can reap it. What you most likely have is a stuck or orphaned Node process that is still alive and listening. The steps below tell you which one you have.
2. Find What's Using Port 8080
sudo ss -ltnp 'sport = :8080'
| Part | Meaning |
|---|---|
sudo |
Needed to see process names and PIDs owned by other users. Read-only, so it's safe. |
ss |
"Socket statistics", the modern replacement for netstat (iproute2, installed by default). |
-l |
Show only listening sockets. |
-t |
TCP only. |
-n |
Numeric output: show 8080 instead of http-alt, and skip DNS lookups. |
-p |
Show the process (name, PID, file descriptor) that owns the socket. |
'sport = :8080' |
Filter on source (local) port 8080. Quote it so bash doesn't interpret the spaces. |
Expected output when something is listening:
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 0 511 0.0.0.0:8080 0.0.0.0:* users:(("node",pid=12345,fd=19))
In this example the PID is 12345. If you see only the header line, nothing is listening and the port is free.
Alternative (install first with sudo apt install lsof if missing):
sudo lsof -nP -iTCP:8080 -sTCP:LISTEN
-n/-P skip host and port name lookups, -iTCP:8080 selects TCP port 8080, and -sTCP:LISTEN keeps only listening sockets. Both tools work. ss is preinstalled and faster, so I recommend it.
Inspect the process before killing it
ps -o pid,ppid,user,stat,etime,%cpu,%mem,cmd -p 12345
| Column | Why you care |
|---|---|
pid / ppid |
The process ID and its parent. A ppid of 1 means it's orphaned (its launching shell is gone), which is typical of nohup or & launches. |
user |
Who runs it. If it's root, that's worth fixing (see step 5). |
stat |
S = sleeping (normal idle), R = running, D = stuck in uninterruptible I/O, Z = zombie, T = stopped. |
etime |
How long it has been running. This helps you confirm it's the old instance. |
cmd |
The full command line. Confirm it really is node app.js and not something else. |
Also check how it was launched, so it doesn't come back:
crontab -l 2>/dev/null | grep -i node # @reboot cron entries
command -v pm2 && pm2 list # PM2-managed processes
screen -ls; tmux ls # detached terminal sessions
3. Stop the Process Safely
⚠️ This affects system state: your app will be down until step 6. Do it in a quiet period.
Escalate gently. Always send SIGTERM before resorting to SIGKILL:
# 1) Polite request: Node gets a chance to close connections and flush logs
kill 12345
# 2) Wait up to 10 seconds, checking whether it's gone
for i in $(seq 1 10); do kill -0 12345 2>/dev/null || { echo "Stopped"; break; }; sleep 1; done
# 3) Only if it's STILL alive: force kill (no cleanup possible)
kill -0 12345 2>/dev/null && kill -9 12345
| Part | Meaning |
|---|---|
kill 12345 |
Sends SIGTERM (15), which asks the process to exit gracefully. |
kill -0 PID |
Sends no signal. It only checks whether the process exists (exit code 0 means alive). Safe to run. |
2>/dev/null |
Hides the "No such process" message once it's gone. |
kill -9 |
SIGKILL. The kernel terminates the process immediately with no cleanup. Use it only as a last resort. |
Prefix kill with sudo only if the process is owned by another user, such as root.
Verify the port is free:
sudo ss -ltnp 'sport = :8080'
Only the header line should remain.
If you truly have a zombie (Z): killing it does nothing. Kill or restart its parent (ppid) instead, and init will reap it. Zombies don't hold ports, though, so a zombie is never the cause of "port in use".
Avoid
fuser -k 8080/tcporpkill nodeas a first move. They kill without showing you what they're killing, andpkill nodewould kill every Node process on the box.
Bonus: find out why it crashes
On a 2 GB droplet, the Linux OOM killer is a prime suspect:
sudo journalctl -k | grep -iE 'out of memory|killed process'
A match like Killed process 12345 (node) means the kernel killed it for using too much memory. Auto-restart helps with this, but the real fix is lowering memory use, capping the heap, or adding swap.
4. Choosing an Approach
| Option | Pros | Cons |
|---|---|---|
| systemd ✅ | Built in, starts at boot, logs go to journald, supports resource limits and sandboxing | Slightly more verbose to configure |
| PM2 | Node-focused, cluster mode, nice dashboard | Extra dependency; it still needs systemd (pm2 startup) to survive reboots |
nohup / screen / cron @reboot |
Quick | No crash restart, poor logging. This is likely your current problem. |
Recommendation: systemd. It's already managing everything else on your server. It also gives you restart-on-crash and start-on-boot without installing anything.
5. Create the systemd Service
Pre-checks
which node # use this ABSOLUTE path in ExecStart
node --version
ls -l /home/deploy/myapp/app.js
⚠️ nvm users:
which nodewill print something like/home/deploy/.nvm/versions/node/v20.x/bin/node. Use that full path. systemd doesn't load nvm, so a barenodewill fail withstatus=203/EXEC.
Write the unit file
⚠️ This runs as root and creates a system file. It's easily undone (see section 7).
sudo tee /etc/systemd/system/myapp.service > /dev/null <<'EOF'
[Unit]
Description=My Node.js app (app.js on port 8080)
# Start after the network is actually up, not just "configured"
After=network-online.target
Wants=network-online.target
# Crash-loop protection: give up if it fails 5 times within 60 seconds
StartLimitIntervalSec=60
StartLimitBurst=5
[Service]
Type=simple
# Run as an unprivileged user, NOT root
User=deploy
Group=deploy
WorkingDirectory=/home/deploy/myapp
# Absolute paths are required. Replace with the output of `which node`.
ExecStart=/usr/bin/node /home/deploy/myapp/app.js
# Restart on crash, and even on an unexpected clean exit
Restart=always
RestartSec=5
# Environment (systemd does NOT read .bashrc or .profile)
Environment=NODE_ENV=production
Environment=PORT=8080
# Optional secrets file; the leading '-' means "ignore if missing"
EnvironmentFile=-/etc/myapp.env
# Logging goes to journald; view with: journalctl -u myapp
StandardOutput=journal
StandardError=journal
SyslogIdentifier=myapp
# Graceful shutdown: SIGTERM first, SIGKILL after 20 seconds
KillSignal=SIGTERM
TimeoutStopSec=20
# Light hardening (safe defaults for most Node apps)
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
LimitNOFILE=65535
[Install]
# Start at boot in normal multi-user mode
WantedBy=multi-user.target
EOF
Key directives explained
| Directive | What it does |
|---|---|
tee ... > /dev/null |
sudo tee writes the file as root. A plain sudo cat > file fails because the redirect runs as your user. > /dev/null stops tee from echoing the file back to you. |
<<'EOF' |
Heredoc. The quotes stop bash from expanding $ inside the file. |
After= / Wants=network-online.target |
Delay startup until networking is fully up. This matters if the app connects to a remote DB at boot. |
StartLimitIntervalSec / StartLimitBurst |
If the app crashes 5 times in 60 seconds, systemd stops retrying and marks it failed, instead of looping forever. |
Type=simple |
The process in ExecStart is the service. Correct for node app.js, which runs in the foreground. |
User= / Group= |
Drop root privileges. Port 8080 is above 1024, so root isn't needed. |
WorkingDirectory= |
Equivalent to cd before starting. Relative paths in your app (./config, .env) resolve from here. |
Restart=always |
Restart after crashes, signals, or any exit. systemctl stop is the exception; it never triggers a restart. on-failure skips restarts on a clean exit 0. For a server that should never exit, always is the safer choice. |
RestartSec=5 |
Wait 5 seconds between restarts. This avoids hammering the CPU and gives the old socket time to close. |
EnvironmentFile=- |
Loads KEY=value lines from a file. Use it for secrets and run chmod 600 on the file. |
SyslogIdentifier= |
The tag shown in journal logs. |
NoNewPrivileges / PrivateTmp / ProtectSystem=full |
Sandboxing. The app can't escalate privileges, gets its own /tmp, and sees /usr, /boot and /etc as read-only. |
LimitNOFILE=65535 |
Raises the open-file limit. Busy Node servers can hit the default under load. |
WantedBy=multi-user.target |
Tells systemctl enable to hook the service into the normal boot sequence. |
Optional for a 2 GB box. You can cap Node's heap so it restarts cleanly instead of triggering the OOM killer on the whole system:
ExecStart=/usr/bin/node --max-old-space-size=768 /home/deploy/myapp/app.js
6. Activate, Verify, and Test
sudo systemd-analyze verify /etc/systemd/system/myapp.service # lint the file (dry-run)
sudo systemctl daemon-reload # make systemd re-read unit files
sudo systemctl enable --now myapp # enable at boot AND start now
| Command | Meaning |
|---|---|
systemd-analyze verify |
Checks syntax without starting anything. No output means no problems. |
daemon-reload |
Required after every edit to a unit file. |
enable --now |
enable creates the boot symlink, and --now also starts the service immediately. |
Check that it's running
systemctl status myapp
Expected output:
● myapp.service - My Node.js app (app.js on port 8080)
Loaded: loaded (/etc/systemd/system/myapp.service; enabled; vendor preset: enabled)
Active: active (running) since Wed 2026-09-23 10:15:02 UTC; 8s ago
Main PID: 23456 (node)
Look for enabled (it will start at boot) and active (running) (it's running now).
sudo ss -ltnp 'sport = :8080' # node should be listening again, now under systemd's PID
curl -I http://localhost:8080 # the app should answer
journalctl -u myapp -f # live logs (Ctrl+C to exit)
Simulate a crash
sudo systemctl kill -s SIGKILL myapp # simulate a hard crash
sleep 6
systemctl status myapp --no-pager # should be "active (running)" with a NEW PID
systemctl show myapp -p NRestarts # restart counter, should be 1 or higher
Test reboot survival (optional, causes brief downtime)
systemctl is-enabled myapp # should print "enabled"
sudo reboot
# after reconnecting via SSH:
systemctl status myapp
7. Edge Cases, Pitfalls and Recovery
| Symptom | Cause | Fix |
|---|---|---|
status=203/EXEC |
Wrong node path (often nvm), or the file isn't executable |
Put the output of which node in ExecStart. |
status=200/CHDIR |
WorkingDirectory is wrong or not readable by User= |
Check the path and ls -ld permissions. |
EADDRINUSE :::8080 in the logs, restart loop |
The old manual instance, PM2 or cron is still holding the port | Repeat steps 2 and 3, and remove the other launcher. |
failed (Result: start-limit-hit) |
It crashed 5 times in 60 seconds | Read journalctl -u myapp -n 50, fix the bug, then sudo systemctl reset-failed myapp && sudo systemctl start myapp. |
| Works manually, fails under systemd | Missing environment variables (systemd has no .bashrc) |
Add Environment= lines or use /etc/myapp.env. |
EACCES writing files |
ProtectSystem=full or file ownership |
Write inside the app directory, or add ReadWritePaths=/path. |
| Edits don't take effect | You forgot to reload | sudo systemctl daemon-reload && sudo systemctl restart myapp |
Day-to-day commands:
sudo systemctl restart myapp # after deploying new code
sudo systemctl stop myapp # stop (it will NOT auto-restart)
journalctl -u myapp --since "1 hour ago"
journalctl -u myapp -b -1 # logs from the PREVIOUS boot (useful after a crash + reboot)
Full undo (⚠️ removes the service):
sudo systemctl disable --now myapp
sudo rm /etc/systemd/system/myapp.service
sudo systemctl daemon-reload
sudo systemctl reset-failed
💡 Best Practice
Edit the unit later with sudo systemctl edit --full myapp instead of opening the file directly. It opens your editor on a temp copy and applies the change when you save. For small tweaks, plain sudo systemctl edit myapp creates a drop-in override (/etc/systemd/system/myapp.service.d/override.conf). Your base unit stays untouched, and you can revert just by deleting the override. Either way, finish with sudo systemctl restart myapp.
Related prompts
Cloud Infrastructure Architect
Design a scalable, secure, cost-optimized cloud architecture with IaC, diagrams, and a phased rollout plan.
Cybersecurity Audit Specialist
Run a structured cybersecurity audit of an organization, prioritizing risks and producing an actionable remediation roadmap.
DevOps Automation Specialist
Acts as a DevOps engineer to design, optimize, and troubleshoot CI/CD pipelines, infrastructure as code, and cloud automation.
Ansible Automation Playbook Creator
Generates production-ready, idempotent Ansible playbooks and roles for any infrastructure automation or configuration task.