Creating a Custom systemd Service
When you deploy your own app to a server, you need it to keep running after you close your terminal, start again automatically when the server reboots, and restart itself if it crashes. The standard way to do this on a modern Ubuntu server is to write a small text file called a systemd unit file (the instructions that tell the system how to run your program). This page shows you how to turn any Node.js or Python app into a managed background service, completely replacing tools like PM2.
What is systemd and why use it
systemd is the program that starts and supervises every background service on Ubuntu. A background program that runs without a terminal attached is called a daemon (pronounced “demon”). systemd is already installed and running as process number 1 on every Ubuntu 22.04 and 24.04 server, so there is nothing extra to install.
A service in systemd is just one daemon that systemd manages for you. You describe your app once in a unit file, and from then on systemd handles starting it, stopping it, restarting it on crash, and bringing it back after a reboot.
When to use this: Use a systemd service for any long-running app you control — a web API, a worker, a bot, a small script that loops forever. Do NOT use it for short, one-off tasks that run and exit (use a cron job or a systemd timer for those instead).
systemd vs PM2 — when to use which
Many Node tutorials reach for PM2 (a Node-specific process manager). On a production Linux server, systemd is the better default.
| Feature | systemd | PM2 |
|---|---|---|
| Pre-installed on Ubuntu | Yes | No (needs npm install) |
| Works for any language | Yes | Node-focused |
| Starts on boot | Built in | Needs extra setup |
| Logs | Central journalctl | Separate log files |
| Resource limits (memory/CPU) | Built in | Limited |
Step 1: Have your app ready
For this example we have a Node.js API living in /opt/myapp, started with node server.js. The same steps work for a Python app — you would just point ExecStart at python3 and your script.
First, find the full path to your runtime so we can use it later.
which node
Output:
/usr/bin/node
Always use the full path in the unit file, because systemd runs with a minimal PATH (the list of folders the system searches for commands) and may not find node by name.
Step 2: Create a dedicated user (recommended)
Running your app as the all-powerful root user is a security risk. If the app is hacked, the attacker gets full control of the server. Instead, create a locked-down user just for the app.
sudo useradd --system --no-create-home --shell /usr/sbin/nologin myapp
The --system flag makes a system account (not a login account), and --shell /usr/sbin/nologin means nobody can log in as it.
Make sure that user can read your app files:
sudo chown -R myapp:myapp /opt/myapp
Step 3: Write the unit file
Custom services go in /etc/systemd/system/. The file name becomes the service name, so myapp.service gives you a service called myapp.
sudo nano /etc/systemd/system/myapp.service
Paste the following:
[Unit]
Description=My Node.js API
# Wait until the network is ready before starting
After=network.target
[Service]
Type=simple
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp
ExecStart=/usr/bin/node /opt/myapp/server.js
# Restart the app if it ever exits or crashes
Restart=on-failure
RestartSec=5
# Pass environment variables to your app
Environment=NODE_ENV=production
Environment=PORT=3000
[Install]
WantedBy=multi-user.target
What each key means
| Key | What it does |
|---|---|
Description | Human-readable name shown in systemctl status. |
After=network.target | Don’t start until networking is up. Use After to set ordering. |
Type=simple | The default: systemd considers the service started as soon as ExecStart runs. |
User / Group | Run the app as this unprivileged user, not root. |
WorkingDirectory | The folder the app runs from, as if you had cd’d into it. |
ExecStart | The exact command to launch the app. Must use full paths. |
Restart=on-failure | Restart only if the app exits with an error. Use always to also restart on clean exits. |
RestartSec | Seconds to wait before restarting, so a broken app doesn’t spin in a tight loop. |
Environment | Sets an environment variable for the process. Repeat for each one. |
WantedBy=multi-user.target | Makes the service start at boot when you enable it. |
For a Python app the only line that changes is ExecStart:
ExecStart=/usr/bin/python3 /opt/myapp/main.py
Gotcha: Do not start the command with
sudoinsideExecStart, and do not add an&to background it. systemd manages the process directly — adding either will break the service.
Step 4: Reload, enable, and start
Whenever you add or edit a unit file, you must tell systemd to re-read its configuration.
sudo systemctl daemon-reload
Now enable the service (so it starts automatically on every boot) and start it right now. You can do both in one command:
sudo systemctl enable --now myapp
Output:
Created symlink /etc/systemd/system/multi-user.target.wants/myapp.service → /etc/systemd/system/myapp.service.
Step 5: Check that it is running
sudo systemctl status myapp
Output:
● myapp.service - My Node.js API
Loaded: loaded (/etc/systemd/system/myapp.service; enabled; preset: enabled)
Active: active (running) since Mon 2026-06-15 10:22:14 UTC; 6s ago
Main PID: 4127 (node)
Tasks: 11 (limit: 4631)
Memory: 38.4M
CPU: 240ms
CGroup: /system.slice/myapp.service
└─4127 /usr/bin/node /opt/myapp/server.js
Active: active (running) and enabled mean the app is up now and will return after a reboot.
Step 6: View the logs
Anything your app prints to the screen is captured by systemd’s logging system, the journal. Read it with journalctl:
sudo journalctl -u myapp -f
The -u flag picks your unit, and -f “follows” the log live (like tail -f). Press Ctrl+C to stop watching.
Everyday commands
| Command | What it does |
|---|---|
sudo systemctl restart myapp | Restart after a code deploy. |
sudo systemctl stop myapp | Stop the app now. |
sudo systemctl disable myapp | Stop it from starting at boot. |
sudo systemctl daemon-reload | Re-read unit files after editing. |
Best Practices
- Run every service as a dedicated unprivileged user, never as
root. - Always use absolute paths in
ExecStart(e.g./usr/bin/node) — the minimal systemdPATHwon’t find bare command names. - Set
Restart=on-failurewith aRestartSecof a few seconds so a crashing app recovers without hammering the CPU. - Keep secrets out of the unit file; load them from an
EnvironmentFile=(e.g./etc/myapp/env) with tightchmod 600permissions. - Run
sudo systemctl daemon-reloadafter every edit, thenrestart, or your changes are ignored. - Add hardening keys like
NoNewPrivileges=trueandProtectSystem=fullto limit what a compromised app can touch.