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
deployexists; - 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.
Requirements
- a user with
sudoprivileges; - disk space and networking — not a concern here, since the app is local and needs no external connectivity;
- the
Type=,ExecStart=, anddaemon-reloadconcepts 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:
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
--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:
Step 2: Prepare the Application Directory and Code
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
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
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
[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:
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
Verification
Confirm the state after each important step:
This should show Active: active (running) and enabled.
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 withjournalctl -u backend-demo.service -n 30(covered in depth in journalctl).- "Permission denied" — usually means the
chown/chmodstep wasn't completed fully, or a path that needs write access wasn't added toReadWritePaths=underProtectSystem=strict. curl: Connection refused— the service hasn't started yet (check withsystemctl status), orAPP_PORTwas 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 underType=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.servicereturnsactive;systemctl is-enabled backend-demo.servicereturnsenabled;curl http://127.0.0.1:8080/returns a successful response;ps -o user -C python3 --no-headersshowsdeploy, notroot.
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.