Run an App as a systemd Service

Keep any program running with a systemd unit: start at boot, restart on failure, log to journald, and schedule jobs with timers instead of cron.

Updated 3 min read

Anything you start by hand in a terminal dies when the terminal does. systemd is how a Linux server keeps a program running: start it at boot, restart it if it crashes, capture its logs. Docker Compose users get most of this from restart: unless-stopped; for everything else (a Node app, a Python bot, a Go binary, a game server you run outside the panel), a unit file is the answer.

A unit file, explained#

Create /etc/systemd/system/myapp.service:

[Unit]
Description=My application
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp
EnvironmentFile=-/opt/myapp/.env
ExecStart=/usr/bin/node /opt/myapp/server.js
Restart=on-failure
RestartSec=5
# hardening (safe defaults for most apps)
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full

[Install]
WantedBy=multi-user.target
  • User: run as a dedicated unprivileged user (useradd -r -s /usr/sbin/nologin myapp), never root.
  • EnvironmentFile: secrets and settings live in a file readable only by that user (chmod 600), not in the unit.
  • ExecStart must be an absolute path. No shell tricks; if you need them, point it at a small script.
  • Restart=on-failure restarts on crashes but not on a clean exit; use always for daemons that should never be down.

Enable and run#

sudo systemctl daemon-reload            # after creating or editing the unit
sudo systemctl enable --now myapp       # start now and at every boot
sudo systemctl status myapp
sudo journalctl -u myapp -f             # live logs; -n 200 for the last 200 lines
sudo systemctl restart myapp            # after deploying a new version

Timers instead of cron#

For scheduled jobs (backups, cleanups), a .timer paired with a oneshot .service logs to journald and survives reboots that cron might have missed:

# /etc/systemd/system/backup.service
[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh

# /etc/systemd/system/backup.timer
[Timer]
OnCalendar=*-*-* 03:30:00
Persistent=true
[Install]
WantedBy=timers.target
sudo systemctl enable --now backup.timer && systemctl list-timers

Docker Compose under systemd (optional)#

restart: unless-stopped in the Compose file is enough for most people. If you want Compose projects to start in a specific order or after a network mount, a unit works too:

[Unit]
Description=Compose project: myapp
Requires=docker.service
After=docker.service network-online.target

[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/opt/myapp
ExecStart=/usr/bin/docker compose up -d
ExecStop=/usr/bin/docker compose down

[Install]
WantedBy=multi-user.target

Debugging a service that will not start#

  1. systemctl status myapp shows the exit code and the last log lines.
  2. journalctl -u myapp -e shows the full log; permission errors and missing files live here.
  3. Run the ExecStart command by hand as the service user: sudo -u myapp /usr/bin/node /opt/myapp/server.js.
  4. Ports below 1024 need a capability (AmbientCapabilities=CAP_NET_BIND_SERVICE) or, better, a reverse proxy in front on 80/443.
Still stuck? Real engineers answer tickets around the clock, and the status page shows anything network-wide before you ask.