To run your application on Linux as a proper service (starting at boot, restarting after a crash, with its logs in one place), you create a unit file at /etc/systemd/system/myapp.service that says which user runs it, from which directory and with what command. Then sudo systemctl daemon-reload and sudo systemctl enable --now myapp enable and start it, and journalctl -u myapp -f shows its logs.

This replaces tricks like nohup ... & or leaving a terminal window open, which disappear at the first server reboot or dropped connection. Below we build a real unit file line by line, go through the commands to manage it, look at common errors, and finish with a few simple hardening options. Examples target Ubuntu 24.04.

Short answer: To create a systemd service, write a unit file such as /etc/systemd/system/myapp.service with [Unit], [Service] and [Install] sections that set the user, working directory, the start command with a full path, and the Restart behaviour. Then run sudo systemctl daemon-reload and sudo systemctl enable --now myapp so it starts now and on every boot, and read its logs with journalctl -u myapp.

What are systemd and units?

systemd is the service manager on most modern Linux distributions: the first process started after the kernel, which starts and stops everything else. Everything systemd manages is a unit: services (.service), timers (.timer), mounts and so on. A unit file is plain text with [Unit], [Service] and [Install] sections.

Unit files installed by packages live in /usr/lib/systemd/system/ and should not be touched. Put your own in /etc/systemd/system/.

Preparation: a user and an application directory

Say we have a small Python web app that runs with gunicorn on port 8000, installed in /opt/myapp with a virtualenv. First, create a system user with no login shell so the app does not run as root:

bash
sudo useradd --system --home /opt/myapp --shell /usr/sbin/nologin myapp
sudo chown -R myapp:myapp /opt/myapp

Keep secrets (database password, keys) in a separate file:

/etc/myapp/myapp.env
DATABASE_URL=postgresql://myapp:secret@127.0.0.1:5432/myapp
APP_ENV=production
bash
sudo chown root:root /etc/myapp/myapp.env
sudo chmod 600 /etc/myapp/myapp.env

systemd itself reads this file before starting the app, so the myapp user does not need access to it. The format is simple: one KEY=value per line, no export.

Writing the unit file

/etc/systemd/system/myapp.service
[Unit]
Description=MyApp web application
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp
EnvironmentFile=/etc/myapp/myapp.env
Environment=PYTHONUNBUFFERED=1
ExecStart=/opt/myapp/venv/bin/gunicorn --workers 2 --bind 127.0.0.1:8000 app:app
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

What each line does:

  • After and Wants: start the service once the network is up. If the app needs a local database, add it too, e.g. After=network-online.target postgresql.service.
  • Type=simple: the app runs in the foreground and does not background itself. This is right for most modern applications.
  • User and Group: the app runs as this user, not root.
  • WorkingDirectory: the app's current directory; relative paths in the code resolve from here.
  • EnvironmentFile and Environment: environment variables. The first reads them from a file, the second defines them inline in the unit.
  • ExecStart: the start command, with a full path.
  • Restart=on-failure: if the app exits with an error or crashes, start it again after RestartSec seconds.
  • WantedBy=multi-user.target: with enable, the service starts on a normal server boot.

Which Restart setting?

Value When does it restart? Good for
no Never (the default) One-off jobs
on-failure Non-zero exit, crash, signal, timeout Most services
always Any exit, even a clean one Workers that exit on purpose after a while

A second example, a Laravel queue worker, is a good fit for always, because with --max-time it deliberately exits every hour to free memory:

/etc/systemd/system/myapp-queue.service
[Unit]
Description=MyApp Laravel queue worker
After=network-online.target redis-server.service

[Service]
User=www-data
WorkingDirectory=/var/www/myapp
ExecStart=/usr/bin/php artisan queue:work --sleep=3 --tries=3 --max-time=3600
Restart=always
RestartSec=3

[Install]
WantedBy=multi-user.target

Queues themselves are covered in Laravel queues for slow work.

Starting and managing the service

bash
sudo systemd-analyze verify /etc/systemd/system/myapp.service   # check for syntax errors
sudo systemctl daemon-reload          # make systemd re-read changed unit files
sudo systemctl enable --now myapp     # enable at boot and start right now
systemctl status myapp                # state, PID, memory use and the last few log lines

Everyday commands:

bash
sudo systemctl restart myapp     # after deploying new code
sudo systemctl stop myapp
sudo systemctl disable myapp     # do not start at boot
systemctl cat myapp              # show the unit exactly as systemd sees it

Every time you change a unit file you need daemon-reload; when only the application code has changed, restart is enough.

Reading logs with journalctl

Whatever the app writes to stdout and stderr ends up in the journal:

bash
journalctl -u myapp -f                    # follow live
journalctl -u myapp -n 100 --no-pager     # last 100 lines
journalctl -u myapp --since "30 min ago"
journalctl -u myapp -b                    # since the last boot only

That is what PYTHONUNBUFFERED=1 in the example above is for: Python's output is not buffered, so log lines show up in the journal immediately. If the journal grows too large, see Linux server disk full.

Why won't my service start? Common errors

Check systemctl status myapp first, then journalctl -u myapp -n 50. The code next to status= is usually the main clue:

  • status=203/EXEC: the ExecStart binary was not found or is not executable. Use the full path (which gunicorn helps) and check chmod +x.
  • status=200/CHDIR: the WorkingDirectory does not exist or the user cannot access it.
  • status=217/USER: the user named in User has not been created.
  • Permission denied in the app's log: the service user does not own a file or directory the app writes to.
  • An environment variable is missing: systemd does not read ~/.bashrc or ~/.profile. Every variable you need must be in Environment or EnvironmentFile.
  • ExecStart with a pipe or >: this line is not a shell; |, >, && and ~ do not work. If you really need them, write ExecStart=/bin/bash -c '...', but a separate script is usually better.
  • The service starts and immediately goes "inactive": the app has backgrounded (daemonised) itself. Either find the app's foreground option or set Type=forking.
  • start request repeated too quickly: the app crashed repeatedly and systemd gave up after several attempts in a short window (by default 5 times in 10 seconds). Find the cause of the crash in the log, then run sudo systemctl reset-failed myapp and start again.
  • A port below 1024: a non-root user cannot listen on port 80 or 443. Run the app on a high port and put nginx in front of it, or add AmbientCapabilities=CAP_NET_BIND_SERVICE.

Basic hardening

systemd can restrict what the app is allowed to do, so that if a vulnerability is ever found in it, an attacker cannot do much. A few simple options that are safe for almost any web app go in the [Service] section:

ini
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/opt/myapp/storage
ProtectHome=true
PrivateTmp=true
  • NoNewPrivileges=true: the app and its children can never gain extra privileges, for example through sudo or setuid binaries.
  • ProtectSystem=strict: the whole filesystem becomes read-only for the app, except the paths listed in ReadWritePaths. If that is too strict, ProtectSystem=full makes only /usr, /boot and /etc read-only.
  • ProtectHome=true: /home and /root are invisible to the app.
  • PrivateTmp=true: the app gets a private /tmp and cannot see other processes' temporary files.

After adding these, run daemon-reload and restart and watch the log; if the app writes somewhere you forgot, you will see a "Read-only file system" error, and you just add that path to ReadWritePaths. To see how locked down a service is:

bash
systemd-analyze security myapp

To change a service installed by a package, do not edit the original file; sudo systemctl edit nginx creates a drop-in file that survives package upgrades.

Frequently asked questions

Where do I put a systemd service file?

Service files you write yourself go in /etc/systemd/system/. Files installed by packages live in /usr/lib/systemd/system/ and should not be edited; use systemctl edit to override them.

What is the difference between systemctl start and systemctl enable?

start runs the service right now but it will not come back after a reboot. enable registers the service to start at boot but does not run it now. enable --now does both at once.

Do I need to run daemon-reload after editing a service file?

Yes. Every time you change a unit file, run sudo systemctl daemon-reload so systemd reads the new version, then restart the service. If only the application code changed and the unit file is untouched, restart alone is enough.

Why can't my systemd service see my environment variables?

Because systemd does not read files like ~/.bashrc or ~/.profile, and the service does not run in your shell's environment. Every variable the app needs must be defined with Environment in the unit itself or with EnvironmentFile from a separate file.

systemd vs. nohup or tmux: which should I use to keep an app running?

For an app that must always be up, systemd is the right tool, because it starts the service at boot, brings it back after a crash and collects logs in the journal. nohup and tmux are lost on reboot and nobody restarts a dead process; tmux is for hands-on work on the server.

Wrap-up

A systemd service is the standard way to run an application on a Linux server: one file in /etc/systemd/system/ with a dedicated user, a WorkingDirectory, a full path in ExecStart, variables in an EnvironmentFile and Restart=on-failure. Then daemon-reload, enable --now, and journalctl -u for logs. When something does not work, the status code and the last few lines of the journal almost always tell you why. A handful of hardening options like NoNewPrivileges and ProtectSystem take a few lines and greatly reduce the cost of a possible compromise. If a job needs to run on a schedule rather than continuously, see cron jobs and systemd timers, and if you run your app in containers, Docker Compose plays the same role for you.