# Creating a Custom Systemd Service

LLMS index: [llms.txt](/en/llms.txt)

---

Your application needs to start on boot, restart on crash, and log output. Shell scripts in /etc/rc.local give you none of that. Systemd solves all three with a single declarative file.

## Why Write a Custom Unit File

Supervisord and init scripts are overkill for most cases. Systemd provides a unified interface for service management: socket-based activation, dependency tracking, resource limits, and built-in logging via journald. You get all of it without additional tooling.

## Unit File Structure

A unit file is an ini-style text file placed in `/etc/systemd/system/` for persistent configuration or `/run/systemd/system/` for runtime-only changes. Naming convention is `name.service`.

```ini
[Unit]
Description=My Application
After=network.target

[Service]
Type=simple
ExecStart=/opt/myapp/bin/start.sh
Restart=on-failure
User=myapp

[Install]
WantedBy=multi-user.target
```

## Required Sections and Directives

| Section | Key | Purpose |
|---------|-----|---------|
| `[Unit]` | `Description` | Human-readable name |
| `[Unit]` | `After` | Startup ordering relative to other units |
| `[Service]` | `Type` | How the service demonizes |
| `[Service]` | `ExecStart` | Command to execute on start |
| `[Install]` | `WantedBy` | Target that enables this unit |

### Type

- `simple` — process stays in foreground, systemd monitors it directly
- `forking` — process forks and parent exits (classic daemon pattern)
- `oneshot` — runs once and exits, useful for one-off tasks
- `exec` — like simple, but waits for ExecStartPre to finish first

For modern applications written in Go, Node.js, or similar, `simple` is almost always correct.

## Example: Application Service

```ini
[Unit]
Description=Backend API Service
Documentation=https://internal.example.com/docs
After=network-online.target postgresql.service
Wants=network-online.target

[Service]
Type=simple
User=appuser
Group=appgroup
WorkingDirectory=/opt/api
ExecStart=/opt/api/start.sh
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=5s
TimeoutStartSec=30s
TimeoutStopSec=60s
Environment=NODE_ENV=production
EnvironmentFile=/etc/default/api
StandardOutput=journal
StandardError=journal
SyslogIdentifier=api-backend

# Hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/api /var/log/api
ProtectKernelTunables=true
ProtectControlGroups=true

[Install]
WantedBy=multi-user.target
```

> [!NOTE]
> The `--user` flag creates a user-level service that lives with the user's session instead of the system. Useful for personal tooling without root access.

### Python: venv and gunicorn

Point `ExecStart` at the venv interpreter, not system `python`. That keeps service dependencies off the host packages:

```ini
[Service]
Type=simple
User=appuser
Group=appuser
WorkingDirectory=/opt/myapp
ExecStart=/opt/myapp/venv/bin/python -m myapp
EnvironmentFile=/etc/myapp/env
Restart=on-failure
RestartSec=5
```

For a WSGI app:

```ini
ExecStart=/opt/myapp/venv/bin/gunicorn --workers 3 --bind 127.0.0.1:8000 myapp.wsgi:application
```

Keep secrets in `EnvironmentFile` (`KEY=VALUE`), mode `600`, owner `root:appuser`. If the unit fails to start, `journalctl -u myapp` usually shows a traceback, a missing `WorkingDirectory`, or a missing env file.

## Production-Ready Directives

### Restart Behavior

```ini
Restart=on-failure      # on non-zero exit code
Restart=on-abnormal     # on signal or timeout
Restart=always          # unconditionally, even after clean stop
RestartSec=5
```

### Dependencies

```ini
After=network.target         # after network is up
After=postgresql.service     # after specific service
Wants=network-online.target  # weak dependency, continue if unavailable
Requires=postgresql.service   # hard dependency, fail if unavailable
```

### Resource Limits

```ini
LimitNOFILE=65536
LimitNPROC=4096
MemoryMax=512M
CPUQuota=50%
```

### Logging

```ini
StandardOutput=journal
StandardError=journal
SyslogIdentifier=myapp
```

Reading logs:

```bash
journalctl -u myapp -f
journalctl -u myapp --since "1 hour ago"
journalctl -u myapp -p err
```

## Activation and Management

```bash
# Reload unit files from disk
sudo systemctl daemon-reload

# Start the service
sudo systemctl start myapp

# Check current status
sudo systemctl status myapp

# Enable on boot
sudo systemctl enable myapp

# Reload config without restarting
sudo systemctl reload myapp

# Full restart
sudo systemctl restart myapp

# Stop
sudo systemctl stop myapp

# Disable from boot
sudo systemctl disable myapp
```

Syntax check without applying changes:

```bash
systemd-analyze verify /etc/systemd/system/myapp.service
```

## Common Mistakes

**Missing ExecStart**. Service fails immediately with `Unit entered failed state`.

**Type not specified**. Defaults to `simple`, but if your process self-daemonizes, you need `forking`.

**Wrong path to script**. Verify the file exists and is executable. Systemd does not validate paths at parse time — it simply fails to start.

**Runtime edit without reload**. Changed the file, forgot `daemon-reload`. Systemd continues using the old version.

**WorkingDirectory does not exist**. If you specify it, the directory must be present.

**Restart=always without ExecStop**. If the process exits cleanly, `always` restarts it anyway. This means `systemctl stop` may not behave as expected for long-running services.

> [!WARNING]
> Never edit package-provided unit files directly in `/usr/lib/systemd/system/`. Updates overwrite them. Use drop-in files in `/etc/systemd/system/<name>.service.d/` instead.

Drop-in example:

```bash
mkdir -p /etc/systemd/system/myapp.service.d
```

```ini
# /etc/systemd/system/myapp.service.d/override.conf
[Service]
Environment=DEBUG=1
RestartSec=10s
```

```bash
sudo systemctl daemon-reload
sudo systemctl restart myapp
```
