The most surprising thing about systemd unit documentation is that the ExecStart directive, despite its central role in defining what a service does, is often the least understood and most frequently misused.

Let’s see systemd in action. Imagine a simple Python script, my_app.py, that listens on port 8080.

# my_app.py
import http.server
import socketserver

PORT = 8080

class Handler(http.server.SimpleHTTPRequestHandler):
    pass

with socketserver.TCPServer(("", PORT), Handler) as httpd:
    print(f"Serving on port {PORT}")
    httpd.serve_forever()

To run this with systemd, you’d create a unit file, say /etc/systemd/system/my_app.service:

[Unit]
Description=My Awesome Python App

[Service]
ExecStart=/usr/bin/python3 /opt/my_app/my_app.py
Restart=on-failure

[Install]
WantedBy=multi-user.target

When you run sudo systemctl start my_app.service, systemd’s systemd-system.slice (or a similar slice) will create a new scope. Within that scope, systemd will execute /usr/bin/python3 /opt/my_app/my_app.py as the primary process for this service. This command is not just a shell command; systemd directly invokes the executable with the provided arguments. It handles process supervision, logging (stdout/stderr go to the journal by default), and resource control based on this directive.

The problem systemd unit files solve is providing a robust, declarative way to manage processes beyond simple init.d scripts. It offers features like dependency management (Requires, Wants), ordering (After, Before), resource isolation (via cgroups), socket activation, and sophisticated restart policies. ExecStart is the core of what process systemd is managing.

The actual levers you control are within the [Service] section. Type (simple, forking, oneshot, dbus, notify) dictates how systemd tracks the main process. ExecStart is the command itself. ExecStop defines how to gracefully shut it down. Restart policies (no, on-success, on-failure, on-abnormal, on-watchdog, on-abort, always) determine its resilience. WorkingDirectory sets the CWD for the process. User and Group specify the execution context. Environment and EnvironmentFile are crucial for configuration.

Consider the Type directive. If your ExecStart command starts a background daemon that forks, and you use Type=simple (the default), systemd will think the service has started as soon as the initial ExecStart process forks. The parent process might exit immediately, leaving the child running, but systemd will report the service as "started" and won’t track the actual daemon process. For forking daemons, Type=forking is essential, and you’ll often need to specify PIDFile so systemd can correctly identify the main daemon process after the fork.

When you need to pass complex arguments or use shell features like pipes, redirection, or environment variable expansion directly within ExecStart, you must explicitly invoke a shell. For example, if you wanted to run a command that pipes its output to grep, you’d write:

ExecStart=/bin/sh -c 'my_command | grep "pattern"'

This is because systemd, by default, executes ExecStart as a direct execve call, not through /bin/sh. The /bin/sh -c wrapper tells systemd to execute the shell, and the shell then interprets the rest of the string as a command to run. This is also how you’d handle things like ExecStart=/usr/bin/env python3 my_app.py if python3 wasn’t in a fixed, known path.

The next concept you’ll likely grapple with is managing dependencies and ordering between services, particularly when one service relies on another (like a web app needing a database) using Requires, Wants, and After directives.

Want structured learning?

Take the full Systemd course →