How to Create a systemd Service: Step by Step, Tested on a Real Server (2026)

How to Create a systemd Service: Step by Step, Tested on a Real Server (2026)

To create a systemd service you write one small text file that tells Linux which program should run in the background, when it should start, and what should happen when it crashes. After that, your script, Node app or Python server starts automatically at boot, gets restarted after a crash and sends its output to a central log. No more screen, no more nohup, no more forgotten terminal tab keeping production alive.

Our own server runs more than a hundred self-written units: websites, APIs, bots, small tools. For this article, on October 5, 2026, we built a test service and broke it on purpose: killed it with kill -9, sent it into a crash loop, capped its memory and hardened it. We also ran systemd’s built-in security check against all of our own units. Three findings up front:

1. Restart=on-failure does not restart a service that is stopped with a normal kill (SIGTERM). To systemd, that is a clean exit. In our test, only kill -9 triggered a restart.

2. A service that keeps crashing is given up for good after 5 attempts in 10 seconds. No alert. It just sits in failed.

3. Out of 104 of our own unit files, not a single one had a good security score. Median: 9.6 out of 10 (“UNSAFE”). With 20 lines of hardening, our test service dropped to 1.5 and kept working exactly as before.

A robot conductor standing in front of a row of small, evenly glowing server cabinets, guiding them with a baton

What is systemd?

systemd is the program that starts first on almost every modern Linux system (it is process number 1) and then brings everything else up: networking, logging, SSH, databases, your own services. Ubuntu, Debian, Fedora, Arch, Rocky Linux, openSUSE: they all use systemd. If you are still setting up your server, our guide to Linux server setup covers what comes before this.

Everything systemd manages is called a unit. There are several kinds:

  • .service: a program that runs (or runs once). That is what this article is about.
  • .timer: a schedule that triggers a service. The modern replacement for many cron jobs.
  • .socket: starts a service only when someone connects to a port.
  • .target: a group of units, such as multi-user.target (“the system has booted normally”).
  • .mount, .path, .slice and a few more for special cases.

Our test system ran systemd 255 (Ubuntu 24.04). Everything here works from roughly version 240 onward, so Debian 12 and newer are fine. Check your version with systemctl --version.

tutoriaLinux explains what an init system does and walks through unit files. Good background before you write your own.

Create a systemd service: the quick version

If you just want a program running as a service, it takes five steps. We explain every line afterwards.

1. Create the unit file (as root or with sudo):

sudo nano /etc/systemd/system/my-app.service

2. Paste this:

[Unit]
Description=My App
After=network.target

[Service]
Type=simple
User=myapp
WorkingDirectory=/opt/my-app
ExecStart=/usr/bin/node /opt/my-app/server.js
Environment=PORT=3000
Restart=on-failure
RestartSec=2

[Install]
WantedBy=multi-user.target

3. Tell systemd about the new file:

sudo systemctl daemon-reload

4. Start it and enable it at boot:

sudo systemctl enable --now my-app

5. Check it:

systemctl status my-app
journalctl -u my-app -f

That’s it. On our server, systemctl start took 19 milliseconds for a small Python web server, and the running service used 9.1 MB of memory. systemd itself costs you practically nothing.

Where do systemd service files go?

This is one of the most common questions, and the answer has a few parts:

LocationPurposeEdit it?
/etc/systemd/system/Your own units and overridesYes, your files belong here
/usr/lib/systemd/system/ (or /lib/systemd/system/)Units shipped by packages (nginx, ssh, …)No, updates overwrite them
/run/systemd/system/Units generated at runtimeNo, gone after reboot
~/.config/systemd/user/User services without rootYes, for your own user services

If a file with the same name exists in several places, /etc/ wins. That lets you replace a package unit entirely without touching the original. Usually, though, you only want to change a few lines, and that is what drop-ins are for:

sudo systemctl edit nginx

This opens an editor and saves your changes to /etc/systemd/system/nginx.service.d/override.conf. The original unit stays untouched, and a package update cannot break your customisation. To see what actually applies in the end:

systemctl cat nginx

Our server has 113 service files in /etc/systemd/system/. Ten of them are just symlinks (aliases, for example); the rest are our own services.

Tiered teal glass pedestals with a small gear and a white sphere, symbolising the three sections of a unit file

Anatomy of a unit file, line by line

A service file almost always has three sections. Case matters, and comments start with #.

[Unit]: description and dependencies

[Unit]
Description=My App
After=network.target
Wants=network-online.target
  • Description=: A readable name. Shows up in systemctl status and in the log.
  • After=: Ordering. “Start me after X has started.” This is not a dependency: if X never starts, your service starts anyway.
  • Wants= / Requires=: These are the dependencies. Wants= pulls X up too but doesn’t care if X fails. Requires= is stricter: if X goes down, your service is stopped with it.

The most common beginner mistake here: writing only After=postgresql.service and wondering why the database isn’t there at startup. You need both: After= for the order and Wants= or Requires= so it gets started at all.

For services that need a real network connection at startup (a remote database, for example), network.target is too early. It only means “network management has started”, not “we have an IP address”. Use network-online.target for that, as a pair: Wants=network-online.target plus After=network-online.target. For a web server that only listens on a local port, network.target is fine.

[Service]: what runs, and how

[Service]
Type=simple
User=myapp
Group=myapp
WorkingDirectory=/opt/my-app
ExecStart=/usr/bin/node /opt/my-app/server.js
Environment=PORT=3000 NODE_ENV=production
EnvironmentFile=/etc/my-app/env
Restart=on-failure
RestartSec=2
  • ExecStart=: The command. Always use an absolute path to the program (/usr/bin/node, not node). systemd does not use a shell, so ~, &&, pipes and $(...) don’t work here. If you need them, write ExecStart=/bin/sh -c '...' or, better, a small start script.
  • User= / Group=: Which user the service runs as. Leave it out and it runs as root. More on that below, because this is exactly where we found a problem on our own server.
  • WorkingDirectory=: The working directory. Important for programs that use relative paths like ./data.
  • Environment= / EnvironmentFile=: Environment variables. Passwords and API keys belong in an EnvironmentFile with mode 600, not in the unit itself, because any user can read units with systemctl cat.
  • Restart= and RestartSec=: What happens when the process exits. There is a whole section with measurements on this below.

There are also ExecStartPre= (runs first, e.g. to validate a config), ExecReload= (for systemctl reload) and ExecStop= (if your program needs a special stop command; otherwise systemd sends SIGTERM and, after 90 seconds, SIGKILL).

[Install]: when it starts at boot

[Install]
WantedBy=multi-user.target

This section is only read by systemctl enable. WantedBy=multi-user.target means: “When the system boots normally, start me too.” That is the right line for almost every server service. Graphical desktop apps would use graphical.target.

Without an [Install] section you can start the service, but not enable it. systemctl enable will complain that the unit has “no installation config”.

Which service types are there?

Type= tells systemd how it knows your service has “finished starting”. This matters because other units wait on it with After=.

Type“Started” when?Use for
simple (default)Immediately after the process is forkedMost of your own apps (Node, Python, Go)
execOnce the binary has been executed successfullyLike simple, but reports errors such as “file not found” more cleanly
forkingWhen the start process exits and leaves a child in the backgroundOld daemons that fork themselves
oneshotWhen the command has finishedScripts that run once (e.g. from a timer)
notifyWhen the program itself reports “ready”Programs with systemd support (nginx, PostgreSQL)

If in doubt, use exec or simple. And importantly: with simple/exec, your program must not put itself into the background. If your start script ends with & or you pass --daemon, systemd sees the service as “exited” immediately while the real process keeps running somewhere, orphaned.

systemctl: the commands you need

sudo systemctl start my-app        # start
sudo systemctl stop my-app         # stop
sudo systemctl restart my-app      # restart
sudo systemctl reload my-app       # reload config (if ExecReload= exists)
sudo systemctl enable my-app       # start at boot
sudo systemctl disable my-app      # don't start at boot anymore
sudo systemctl enable --now my-app # enable AND start right now
systemctl status my-app            # state + last log lines
systemctl is-active my-app         # just "active"/"inactive"/"failed" (good for scripts)
systemctl list-units --type=service --state=failed   # everything currently broken
sudo systemctl daemon-reload       # after EVERY change to a unit file

enable and start are two different things, and that confuses almost everyone at first. start starts it now; enable only makes sure it starts on the next boot. A service can be enabled and stopped, or running and not enabled. The latter is the classic trap: works perfectly until the first server reboot.

The daemon-reload trap, caught live

In our test, we changed the memory limit from 100M to 90M and then only ran systemctl restart, without daemon-reload. Result: systemd printed a warning (“The unit file … changed on disk. Run ‘systemctl daemon-reload’”), but restarted the service anyway, with the old 100 MB limit. Only after daemon-reload did it show 90 MB.

That warning only appears in the terminal. In a deploy script that hides output, your service keeps running with the old configuration and everything looks fine. So: always run daemon-reload after changing a unit file.

Logs: journalctl instead of log files

Everything your program writes to standard output or standard error (console.log, print, echo) automatically ends up in the journal. You don’t need your own log files.

journalctl -u my-app              # all logs of the service
journalctl -u my-app -f           # follow live (like tail -f)
journalctl -u my-app -n 50        # last 50 lines
journalctl -u my-app --since "1 hour ago"
journalctl -u my-app -b           # only since the last boot
journalctl -u my-app -p err       # errors only

A Python gotcha we know first-hand: Python buffers its output when it isn’t writing to a terminal, so you see nothing in the journal for minutes. Fix: print(..., flush=True), or Environment=PYTHONUNBUFFERED=1 in the unit.

To keep the journal from growing forever, set a cap like SystemMaxUse=1G in /etc/systemd/journald.conf. journalctl --disk-usage shows how much space it uses right now.

Restart on crash: what Restart= actually does

This is why most people create a systemd service in the first place: the service should come back by itself. The options:

Restart=Restarts on …
no (default!)never
on-failurenon-zero exit code, death by signal (e.g. SIGKILL, SIGSEGV), timeout
on-abnormalsignal or timeout, but not non-zero exit code
on-abortonly unexpected signals
alwaysalways, even after a clean exit

Important: the default is no. Leave out Restart= and you get no automatic restart.

On our server it breaks down like this: of our service files with a restart line, 74 use always, 26 use on-failure and one uses on-abort.

Our test: kill -9 vs. kill

We ran a small Python web server as a service with Restart=on-failure and RestartSec=2, then triggered two kinds of “crash”.

kill -9 (SIGKILL): The journal showed Main process exited, code=killed, status=9/KILL, then Failed with result 'signal', and exactly two seconds later Scheduled restart job, restart counter is at 1. The service was back, with a new PID.

Plain kill (SIGTERM): The service ended up inactive, with the message Deactivated successfully. No restart. To systemd, SIGTERM, SIGINT, SIGHUP and SIGPIPE are a clean exit, because that is exactly how systemd itself stops services.

That’s not a bug, but it is surprising. If any other process (another script, an overeager admin, an OOM mechanism outside systemd) ends your service with SIGTERM, it stays down under on-failure. If you don’t want that, use Restart=always. systemctl stop still works reliably then: systemd knows it stopped the service and won’t restart it.

A small robot repeatedly lifting a fallen glowing cube back onto a pedestal, behind it a gate with five lamps, the last one glowing red as the gate closes

The crash loop: when systemd gives up

Second test: we replaced ExecStart= with a program that exits immediately with code 3, and set RestartSec=100ms. After four seconds, the journal said:

gm-demo.service: Scheduled restart job, restart counter is at 5.
gm-demo.service: Start request repeated too quickly.
gm-demo.service: Failed with result 'exit-code'.
Failed to start gm-demo.service - getmind systemd demo.

systemctl show confirmed: NRestarts=5, StartLimitBurst=5, StartLimitIntervalUSec=10s. Those are the defaults. More than five starts within ten seconds and it’s over. The service sits in failed and stays there. systemd never tries again on its own, and it doesn’t notify you either.

This is a deliberate safety feature (a broken service shouldn’t keep the server busy in an endless loop). But combined with a short RestartSec=, a brief hiccup (database starting slowly, network gone for a moment) can take your service down permanently. What we recommend:

[Unit]
StartLimitIntervalSec=300
StartLimitBurst=10

[Service]
Restart=on-failure
RestartSec=5

Note: StartLimitIntervalSec= and StartLimitBurst= go in [Unit], not [Service]. Older tutorials still show StartLimitInterval= in the service section; that is deprecated.

After a start-limit failure, reset the counter like this:

sudo systemctl reset-failed my-app
sudo systemctl start my-app

And so you actually hear about such a failure, OnFailure= can trigger another unit that sends you an email or chat message:

[Unit]
OnFailure=notify@%n.service

%n is replaced by the name of the failed unit. A simpler start: check systemctl list-units --state=failed regularly.

Which user does your service run as?

Without User=, the service runs as root. That means a vulnerability in your app is immediately a vulnerability with full rights on the whole server. How fast that becomes real, we described in our report on a server that was hijacked for five days.

Then we counted on our own server, and it wasn’t pretty: of 113 service files in /etc/systemd/system/, 89 have no User= line at all, and another 18 explicitly set User=root. Only 6 run under their own user. The reason is the usual one: when you set things up quickly, root just works, while a dedicated user means sorting out directory permissions first. And afterwards nobody ever touches the file again.

Here is how to do it right:

sudo useradd --system --no-create-home --shell /usr/sbin/nologin myapp
sudo chown -R myapp:myapp /opt/my-app/data
[Service]
User=myapp
Group=myapp

Even simpler is DynamicUser=yes: systemd creates a throwaway user with a random ID at start and removes it afterwards. In our test, the service ran as user ID 61657 instead of 0. If the service needs persistent writable data, declare it with StateDirectory=my-app. systemd then creates /var/lib/my-app and gives the dynamic user access to it.

Programs that need a port below 1024 (like 80 or 443) don’t need root for that either: AmbientCapabilities=CAP_NET_BIND_SERVICE is enough. Or let them listen on a high port and put a reverse proxy in front, which is what we usually do.

Hardening a systemd service: from 9.6 to 1.5

systemd ships with a built-in security check:

systemd-analyze security my-app

It lists around 80 settings and gives an overall score from 0 (tightly sandboxed) to 10 (everything allowed). A normal unit like the quick-start one above scores 9.6, “UNSAFE”.

We ran all of our own units through the check. Result: 104 units scored, median 9.6. Not a single one was below 5, except our test service. For comparison, systemd’s own services on the same machine (systemd-resolved, systemd-timesyncd, systemd-networkd) scored between 2.1 and 2.8. Even the unit Ubuntu ships for SSH scores 9.6, and Caddy gets 8.8. So a high score is the rule rather than the exception. It doesn’t mean the service has been compromised; it means that if it ever is, it can do almost anything.

These are the lines we added to our test service:

[Service]
DynamicUser=yes
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
ProtectProc=invisible
RestrictAddressFamilies=AF_INET AF_INET6
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
SystemCallArchitectures=native
SystemCallFilter=@system-service
CapabilityBoundingSet=
UMask=0077

Result: 1.5, “OK”. And the service answered requests exactly as before.

What the most important lines do:

  • ProtectSystem=strict: The whole filesystem is read-only for the service. It can only write where you allow it via ReadWritePaths= or StateDirectory=.
  • ProtectHome=yes: /home and /root are invisible. A hijacked web app can’t read SSH keys from home directories.
  • PrivateTmp=yes: A private, empty /tmp just for this service.
  • NoNewPrivileges=yes: The process can never gain more privileges, not even via setuid programs like sudo.
  • RestrictAddressFamilies=: Only the listed socket types. A web service needs neither Bluetooth nor raw sockets.
  • SystemCallFilter=@system-service: A list maintained by systemd of the system calls normal services need. Everything else is blocked.

An honest caveat: not every line fits every program. MemoryDenyWriteExecute=yes, for example, breaks programs that generate machine code at runtime, including Node.js and Java (JIT compilers). Our test service was written in Python, so it worked. For Node, drop that line. Go step by step: add a few lines, daemon-reload, restart, test, read the journal. If something gets blocked, it usually shows up there as “Permission denied” or “Operation not permitted”.

A small glowing program cube inside several nested glass boxes in a server room, with only one narrow window open towards a network cable

Limiting resources: memory and CPU

systemd puts every service into its own control group (cgroup). That lets you set limits:

[Service]
MemoryMax=500M
CPUQuota=50%
TasksMax=100

We tested this with a program that allocates more and more memory in a loop, started with MemoryMax=50M (and no swap). At around 40 MB it was over:

gm-hog.service: A process of this unit has been killed by the OOM killer.
gm-hog.service: Main process exited, code=killed, status=9/KILL
gm-hog.service: Failed with result 'oom-kill'.

Only that one service was hit; the rest of the server didn’t notice. Without a limit, the program would have eaten memory until the kernel killed some process, and that isn’t always the culprit. How much RAM a server needs in the first place, we measured in a separate article.

Handy for experiments is systemd-run: it starts a command as a transient service, no unit file needed:

sudo systemd-run --unit=test -p MemoryMax=50M /usr/bin/python3 script.py

systemd-cgtop shows current usage of all services.

An invisible bug we found on our own server

Before you enable a unit, there is a command worth running that hardly anyone knows:

systemd-analyze verify /etc/systemd/system/my-app.service

When we checked our test service with it, the command reported an error in a completely different unit on our server, a drop-in file of a production service:

override.conf:6: Invalid environment assignment, ignoring: Maps
override.conf:6: Invalid environment assignment, ignoring: <hello@...>

The file contained a line like Environment=MAIL_FROM=Project Maps <hello@...>, without quotes. systemd splits Environment= on spaces. So the MAIL_FROM variable contained only the first word of the sender name, and the rest was thrown away. The service had been running the whole time, systemctl status was green, no error anywhere. The message only appears in the journal when the configuration is loaded, and nobody looks there.

Correct would be:

Environment="MAIL_FROM=Project Maps <hello@example.com>"

Or, cleaner: move such values into an EnvironmentFile, where normal quoting rules apply. We deliberately didn’t change that production service in passing for this article; it’s noted as a task. The point is: a running service is no proof of a correct configuration.

systemd timers: the modern cron job

A timer is a second unit file that triggers a service on a schedule. Example: a backup script every day at 3:15 AM.

/etc/systemd/system/backup.service:

[Unit]
Description=Nightly backup

[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh

/etc/systemd/system/backup.timer:

[Unit]
Description=Backup every day at 3:15

[Timer]
OnCalendar=*-*-* 03:15:00
Persistent=true
RandomizedDelaySec=5m

[Install]
WantedBy=timers.target

You enable the timer, not the service:

sudo systemctl daemon-reload
sudo systemctl enable --now backup.timer
systemctl list-timers

You can check the OnCalendar= expression beforehand, which has saved us from wrong schedules more than once:

systemd-analyze calendar 'Mon..Fri 03:15'
#   Normalized form: Mon..Fri *-*-* 03:15:00
#       Next elapse: Tue 2026-10-06 03:15:00 UTC

In our test, a timer with OnCalendar=*:*:0/20 (every 20 seconds) fired exactly at 07:03:40, 07:04:00 and 07:04:20. For that, though, we had to set AccuracySec=1s. By default, systemd coalesces timers within a one-minute window to save power. Irrelevant for a nightly backup, not for anything that needs to run on the second.

What timers do better than cron: output goes to the journal automatically, Persistent=true catches up on missed runs (server was off at 3:15), runs can’t overlap (a service never runs twice at once), and all the hardening and resource options from this article apply. What cron does better: one line instead of two files. For simple tasks, cron is perfectly fine.

An hourglass with teal sand next to a row of evenly glowing glass orbs, symbolising a recurring timer

User services: no root, in your own account

No root access, or the service should explicitly run as your own user? Use user units:

mkdir -p ~/.config/systemd/user
nano ~/.config/systemd/user/my-app.service
systemctl --user daemon-reload
systemctl --user enable --now my-app
journalctl --user -u my-app

The file looks the same, just WantedBy=default.target instead of multi-user.target, and no User= line.

The trap: by default, user services only run while you are logged in. Log out of SSH and systemd stops them. To keep them running without a login and after a reboot, you need lingering:

sudo loginctl enable-linger yourusername
loginctl show-user yourusername -p Linger   # Linger=yes

Our own server has exactly this case: a central service runs as a user unit, and without linger it would be gone after every reboot until someone logged in. Also remember: systemctl restart xy and systemctl --user restart xy are two different worlds. Pick the wrong one and you restart the wrong service, or none at all.

Anton Putra builds a service from scratch, including restart behaviour and running as a dedicated user. A good visual companion to the quick start above.

Troubleshooting: when the service won’t start

When systemctl start fails, we always go in this order:

  1. systemctl status my-app: shows the state, exit code and the last ten log lines. Often the answer is right there.
  2. journalctl -u my-app -n 100 --no-pager: more context.
  3. systemd-analyze verify /etc/systemd/system/my-app.service: typos and invalid lines.
  4. Run the ExecStart= command by hand, as the same user: sudo -u myapp /usr/bin/node /opt/my-app/server.js. If it doesn’t work that way, systemd isn’t the problem.

The most common errors and what they mean:

MessageCause
status=203/EXECProgram not found or not executable. Relative path, missing chmod +x or wrong shebang line
status=200/CHDIRWorkingDirectory= doesn’t exist or isn’t accessible to the user
status=217/USERThe user from User= doesn’t exist
Start request repeated too quicklyStart limit hit (see above), reset-failed first
Unit … not foundFile misnamed (.service extension?) or daemon-reload forgotten
Works by hand, not as a serviceMissing environment variables (PATH, HOME), different user, different working directory

The last one is the sneakiest. A service starts with an almost empty environment, without your .bashrc and without the paths your Node version manager adds there. That’s why node works in the terminal but not in ExecStart=. which node gives you the absolute path.

systemd service vs. Docker vs. PM2

Does it have to be a systemd unit at all? A quick take:

  • systemd: Already there, costs nothing, handles restarts, logs, resource limits, hardening and schedules. Ideal for individual programs directly on the server.
  • Docker / Podman: Makes sense when an app brings lots of dependencies or must run identically on several servers. Podman can even run containers as systemd units (Quadlet); more in Docker vs Podman. And the Docker daemon itself? Started by systemd.
  • PM2, forever, supervisord: Process managers from before systemd or for systems without it. On a normal Linux server they are an extra layer that also has to be started (usually by a systemd unit).

Our approach: individual Node and Python services run directly as systemd units; bigger bundles with a database and extras run as containers.

Frequently asked questions

How do I create a systemd service?

Create a file /etc/systemd/system/NAME.service with [Unit], [Service] (at minimum ExecStart= with an absolute path) and [Install] (WantedBy=multi-user.target). Then run sudo systemctl daemon-reload and sudo systemctl enable --now NAME.

Where are systemd service files located?

Your own units go in /etc/systemd/system/. Package units live in /usr/lib/systemd/system/ (don’t edit them there), user units in ~/.config/systemd/user/. systemctl cat NAME shows which file is actually used.

What is the difference between enable and start?

start starts the service now. enable makes it start on the next boot. enable --now does both.

Do I need to run daemon-reload after every change?

Yes. Without systemctl daemon-reload, systemd keeps using the old version of the file, even on restart. In our test, the service kept the old memory limit after a restart without a reload.

How do I restart a service automatically when it crashes?

Add Restart=on-failure (or Restart=always) and RestartSec=5 to the [Service] section. Note that on-failure doesn’t restart after SIGTERM, and by default systemd gives up for good after 5 crashes in 10 seconds.

What does “Start request repeated too quickly” mean?

The service crashed too many times in a row and hit the start limit (default: 5 starts in 10 seconds). Find the cause in the journal, fix it, then run systemctl reset-failed NAME and start it again. Set more generous values with StartLimitIntervalSec= and StartLimitBurst= in the [Unit] section.

How do I view the logs of a systemd service?

journalctl -u NAME. Follow live with -f, last lines with -n 50, only since the last boot with -b.

How do I run a service as a non-root user?

Either set User= and Group= in the unit (the user must exist), or use DynamicUser=yes for an automatically created throwaway user. Alternatively, run it as a user unit in ~/.config/systemd/user/, but then with loginctl enable-linger.

Which is better, systemd timer or cron job?

Timers give you journal logging, catch-up of missed runs, protection against overlap and the same hardening options as services. Cron is simpler (one line). We use timers for important jobs like backups and cron for small stuff.

How do I delete a systemd service?

sudo systemctl disable --now NAME, then remove the file from /etc/systemd/system/ (plus any NAME.service.d/ directory), then sudo systemctl daemon-reload and sudo systemctl reset-failed.

How secure is a systemd service by default?

Not very. Without User= it runs as root; without hardening options it can do almost anything. systemd-analyze security NAME gives a score from 0 to 10. A default unit scores 9.6; with the options from this article we got to 1.5.

Conclusion: five lines to start, twenty for peace of mind

Creating a systemd service is quick: one file, daemon-reload, enable --now. That alone puts your program in better hands than any screen session. The real work starts afterwards, and our tests show where the silent gaps are:

  • Restart= is off by default, and on-failure doesn’t react to every kind of exit.
  • The start limit shuts a service down permanently after five quick crashes, and nobody tells you.
  • Without User=, everything runs as root, which was the case for 107 of our 113 service files.
  • Hardening costs 20 lines and took our test service from 9.6 to 1.5 without it noticing a thing.
  • A green status proves nothing: systemd-analyze verify found a truncated environment variable on our server that nobody had noticed.

If your service is a web service, next comes a reverse proxy in front (Caddy or nginx) and a firewall (UFW setup). And if your server logs SSH logins, Fail2ban turns those into bans.