Production Operations on a Single Server · 2/10

Run a Web Application as a systemd Service

What will this help you do?

Move an app out of a temporary terminal session. Define a dedicated user, working directory, environment file, restart behavior, and logs, then verify the service safely.

Who is it for?

Readers working through this problem and checking each step.

Before you start

Check the scope of this guide, then prepare the environment and materials it actually calls for.

Updated

On this page

Your website should survive a closed SSH session

Running npm start in a terminal proves that an application can start; it does not make it production-ready. The site may disappear when the SSH session closes, the server restarts, or the process crashes. A systemd unit records how the service should run: which user owns the process, where it starts, what happens on failure, and where its logs go.

First complete the new-server setup and SSH and firewall security guides. The examples below use myapp and 127.0.0.1:3000; replace paths and commands with your application's real values.

Test the start command as a dedicated user

Create a system user that cannot log in interactively, and give it access only to the directories it needs. Do not write a unit file and repeatedly guess at permissions. First run the exact production start command as that user with the intended environment.

bash
sudo useradd --system --home /opt/myapp --shell /usr/sbin/nologin myapp
sudo chown -R myapp:myapp /opt/myapp
sudo -u myapp -H /usr/bin/node /opt/myapp/server.js

Confirm that the app listens only on 127.0.0.1:3000, then request http://127.0.0.1:3000/health. Find absolute paths to Node, pnpm, or another runtime with command -v; systemd will not automatically load your interactive shell configuration.

Keep the environment file separate from the unit

Store non-public configuration in /etc/myapp/myapp.env. Make it owned by root, grant access only to the application group if needed, and use permissions such as 640 or stricter. An environment file is not a vault: processes allowed to read it can still see its contents. Particularly sensitive secrets may need a dedicated secrets mechanism and rotation plan.

ini
# /etc/systemd/system/myapp.service
[Unit]
Description=My website application
After=network.target

[Service]
Type=simple
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp
EnvironmentFile=/etc/myapp/myapp.env
ExecStart=/usr/bin/node /opt/myapp/server.js
Restart=on-failure
RestartSec=5s

[Install]
WantedBy=multi-user.target

Avoid a complicated shell command in ExecStart. If building, migrating data, and starting the app are separate stages, build during deployment, control migration explicitly, and let the service start an already prepared release.

Load, start, and verify in separate steps

bash
sudo systemd-analyze verify /etc/systemd/system/myapp.service
sudo systemctl daemon-reload
sudo systemctl start myapp
sudo systemctl status myapp --no-pager
curl -fsS http://127.0.0.1:3000/health
sudo systemctl enable myapp

Start and check health before enabling startup at boot. An active status proves that a process exists, not that the website works. Request the health endpoint. Then perform a controlled reboot test and confirm that both the application and reverse proxy return automatically.

A more aggressive restart policy is not always safer

Restart=on-failure suits an unexpected exit; RestartSec leaves time for a dependency to recover and for useful logs to be recorded. Do not restart a misconfigured program in a tight loop: that can flood logs and hide the first error. If systemd's start limit is reached, investigate the cause before using reset-failed.

Whether a clean application exit should trigger a restart depends on what that process is meant to do. A scheduled job should not masquerade as a long-running service, and a one-off migration must not be set to restart forever.

Read the current incident in journalctl

bash
journalctl -u myapp -n 100 --no-pager
journalctl -u myapp -f
journalctl -u myapp --since '20 minutes ago'

Logs should include timestamps, severity, and enough request context for diagnosis, but never passwords or complete tokens. If the app writes its own log files, configure rotation. If it writes to the journal, check the system's disk and retention limits.

What counts as done?

The app stays up after SSH closes and returns after a reboot. It does not run as root, environment-file permissions are understood, the health endpoint succeeds, and journalctl shows both a normal start and a useful failure reason. Next, route public traffic to it with Caddy for multiple sites.

Sources

  1. systemd.servicesystemd
  2. systemd.execsystemd

Next steps

Continue with the next useful task; you do not need to read everything at once.

  1. Deploy Open WebUI with Docker and Persistent Storage →
  2. Install Ollama on Linux and Expose It Safely →
  3. Host Multiple Websites on One Server with Caddy →
RESOURCES I USE · REFERRAL

Two practical services to consider only when needed

This is not an automated ranking, and neither service is necessary for everyone. These are services I use, with the use case and limitations kept visible.

Referral disclosure: these links contain my referral information. I may receive a platform benefit if you sign up or order, at no additional charge from AIOOS. Check the order page for current pricing, availability, regions and terms. Read the full affiliate disclosure