Skip to content

Deploying a Backend App as a Service

Creating a Custom systemd Service covered how to write a .service file using a simple demo script. This article applies that knowledge to a realistic scenario: turning a small Python backend application into a systemd service that meets near-production requirements — a dedicated user, an environment file, automatic recovery, and basic security hardening. This is also the foundation any backend sitting behind a reverse proxy needs: running as a stable, supervised service exactly like this before a web server is ever placed in front of it.

Goal and End Result

By the end of this lab:

  • a dedicated, login-less system user named deploy exists;
  • the backend application runs as that user, without root privileges;
  • application configuration (such as the port) lives in a separate environment file, not inside the unit file itself;
  • the application starts automatically when the server reboots, and recovers automatically from an unexpected exit;
  • application logs are visible through journalctl, with no separate log file needed.

Starting State

A disposable test VM or lab directory running Ubuntu Server LTS with python3 installed (present by default). This lab generates no real production traffic — the goal is purely to reinforce the mechanics of running something as a proper service.

python3 --version
Python 3.12.3

Requirements

  • a user with sudo privileges;
  • disk space and networking — not a concern here, since the app is local and needs no external connectivity;
  • the Type=, ExecStart=, and daemon-reload concepts from Creating a Custom systemd Service.

Safety and Snapshot Note

This lab involves creating a new user and starting a service that listens on a new port — both reversible actions, but still best done in a disposable test VM. Record the current state before starting:

systemctl list-units --type=service --state=running | wc -l

This number is used at the end of the lab to confirm that exactly one new service was added.

Step-by-Step Tasks

Step 1: Create a Dedicated User

sudo useradd --system --no-create-home --shell /usr/sbin/nologin deploy
  • --system — creates a user intended for system services, with a low UID range;
  • --no-create-home — no home directory is needed, since the application runs from its own directory;
  • --shell /usr/sbin/nologin — this user cannot log in interactively; it exists only to run the service.

Verification:

id deploy
uid=998(deploy) gid=998(deploy) groups=998(deploy)

Step 2: Prepare the Application Directory and Code

sudo mkdir -p /opt/backend-demo
sudo nano /opt/backend-demo/app.py
/opt/backend-demo/app.py
import http.server
import os
import socketserver

PORT = int(os.environ.get("APP_PORT", "8080"))

class Handler(http.server.BaseHTTPRequestHandler):
    def do_GET(self):
        self.send_response(200)
        self.send_header("Content-Type", "text/plain")
        self.end_headers()
        self.wfile.write(b"Backend app is running\n")

with socketserver.TCPServer(("127.0.0.1", PORT), Handler) as httpd:
    print(f"Listening on 127.0.0.1:{PORT}")
    httpd.serve_forever()

This is a minimal HTTP server that uses only the Python standard library, with no external dependencies — the goal is a simple, easy-to-verify stand-in for a real application, which can be swapped in later without changing anything about how the service itself is configured.

Step 3: Create the Environment File

sudo nano /opt/backend-demo/backend-demo.env
/opt/backend-demo/backend-demo.env
APP_PORT=8080

Keeping configuration separate from the unit file, in its own environment file, means changing the port or another value only requires editing this file — not the unit file — and does not require a daemon-reload (only a service restart).

Step 4: Set Ownership and Permissions

sudo chown -R deploy:deploy /opt/backend-demo
sudo chmod 640 /opt/backend-demo/backend-demo.env

The deploy user needs to own the directory where its application lives; the environment file, since it might hold a password or other sensitive value, is restricted so that only its owner and group can read it.

Step 5: Create the Unit File

sudo nano /etc/systemd/system/backend-demo.service
/etc/systemd/system/backend-demo.service
[Unit]
Description=Backend demo application
After=network.target

[Service]
Type=simple
User=deploy
Group=deploy
WorkingDirectory=/opt/backend-demo
EnvironmentFile=/opt/backend-demo/backend-demo.env
ExecStart=/usr/bin/python3 /opt/backend-demo/app.py
Restart=on-failure
RestartSec=5

NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/backend-demo

[Install]
WantedBy=multi-user.target

Notes on the new lines:

Directive Purpose
EnvironmentFile= Loads KEY=VALUE lines from the named file as environment variables
NoNewPrivileges=true The process and its children cannot gain additional privileges (for example via setuid)
ProtectSystem=strict Makes the entire filesystem read-only for the service, except paths listed in ReadWritePaths=
ProtectHome=true Blocks access to /home, /root, and /run/user entirely
ReadWritePaths= The one directory granted write access as an exception to ProtectSystem=strict

Info

These directives are an example of systemd's sandboxing capabilities — they limit the damage a compromised service could do to the rest of the system. The full list is documented in man systemd.exec.

Interview angle

This demo binds 127.0.0.1:8080, a high port any user may use. The classic follow-up is: how does a non-root service bind a privileged port like 80 or 443? Ports below 1024 normally require root, but dropping User=deploy to gain them defeats the whole point of an unprivileged service. The systemd-native answer is to grant just that one capability instead:

[Service]
AmbientCapabilities=CAP_NET_BIND_SERVICE

The process still runs as deploy but is now allowed to bind low ports — far narrower than running as root. (Lowering sysctl net.ipv4.ip_unprivileged_port_start, or terminating TLS at a reverse proxy so the app keeps its high port, are the other two common answers.)

Step 6: Activate the Service

sudo systemctl daemon-reload
sudo systemctl enable --now backend-demo.service

Verification

Confirm the state after each important step:

systemctl status backend-demo.service

This should show Active: active (running) and enabled.

curl http://127.0.0.1:8080/
Backend app is running
ps -o user,pid,cmd -C python3
USER       PID CMD
deploy    6301 /usr/bin/python3 /opt/backend-demo/app.py

deploy in the USER column confirms the application is running as the dedicated user, not root.

Expected Result

The service is enabled and active (running), curl gets a response from 127.0.0.1:8080, and the process owner is deploy, not root. The result of systemctl list-units --type=service --state=running | wc -l should be exactly one greater than the starting count.

Troubleshooting Tips

  • Active: failed — first read the latest error with journalctl -u backend-demo.service -n 30 (covered in depth in journalctl).
  • "Permission denied" — usually means the chown/chmod step wasn't completed fully, or a path that needs write access wasn't added to ReadWritePaths= under ProtectSystem=strict.
  • curl: Connection refused — the service hasn't started yet (check with systemctl status), or APP_PORT was changed to a different value in the environment file.
  • Service starts and immediately fails — check that ExecStart= uses an absolute path and that the interpreter is invoked directly (as in this example, /usr/bin/python3), which is required under Type=simple.

Rollback and Cleanup

Once testing is done, or if the service needs to be removed entirely:

sudo systemctl disable --now backend-demo.service
sudo rm /etc/systemd/system/backend-demo.service
sudo systemctl daemon-reload
sudo rm -rf /opt/backend-demo
sudo userdel deploy

Danger

rm -rf /opt/backend-demo only deletes the directory created for this lab — read the path carefully before running it, since rm -rf deletes without confirmation and cannot be undone. userdel should only ever target the deploy user created for this lab — make sure a production system doesn't already have a different user with the same name.

Final Assessment Criterion

The lab is considered complete if all of the following are true:

  • systemctl is-active backend-demo.service returns active;
  • systemctl is-enabled backend-demo.service returns enabled;
  • curl http://127.0.0.1:8080/ returns a successful response;
  • ps -o user -C python3 --no-headers shows deploy, not root.

Summary

Turning a real backend application into a systemd service adds three things on top of the core directives from Creating a Custom systemd Service: a dedicated, unprivileged user, an EnvironmentFile= that separates configuration from both code and the unit file, and a security boundary through sandboxing directives such as ProtectSystem=/NoNewPrivileges=. This pattern stays essentially the same regardless of port or interpreter — Python, Node.js, Go, Java. The next article, journalctl, shows how to read and filter this service's logs efficiently.

References