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.servicewith[Unit],[Service]and[Install]sections that set the user, working directory, the start command with a full path, and theRestartbehaviour. Then runsudo systemctl daemon-reloadandsudo systemctl enable --now myappso it starts now and on every boot, and read its logs withjournalctl -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:
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:
DATABASE_URL=postgresql://myapp:secret@127.0.0.1:5432/myapp
APP_ENV=production
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
[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:
AfterandWants: 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.UserandGroup: the app runs as this user, not root.WorkingDirectory: the app's current directory; relative paths in the code resolve from here.EnvironmentFileandEnvironment: 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 afterRestartSecseconds.WantedBy=multi-user.target: withenable, 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:
[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
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:
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:
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: theExecStartbinary was not found or is not executable. Use the full path (which gunicornhelps) and checkchmod +x.status=200/CHDIR: theWorkingDirectorydoes not exist or the user cannot access it.status=217/USER: the user named inUserhas 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
~/.bashrcor~/.profile. Every variable you need must be inEnvironmentorEnvironmentFile. ExecStartwith a pipe or>: this line is not a shell;|,>,&&and~do not work. If you really need them, writeExecStart=/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 runsudo systemctl reset-failed myappandstartagain.- 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:
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 throughsudoor setuid binaries.ProtectSystem=strict: the whole filesystem becomes read-only for the app, except the paths listed inReadWritePaths. If that is too strict,ProtectSystem=fullmakes only/usr,/bootand/etcread-only.ProtectHome=true:/homeand/rootare invisible to the app.PrivateTmp=true: the app gets a private/tmpand 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:
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.