How to Run a Python App as a Service Without Docker (venv, systemd, Logging and Updates Done Right)

Docker (102) is the default for apps in this series, and for good reasons. But a small Python script that polls an API, a Flask or FastAPI app you wrote, a bot — sometimes a container is more ceremony than the job deserves, or the thing needs direct hardware access (GPIO on a Pi, a serial port) that containers make awkward. The host can run it properly: isolated by a user and a virtual environment, supervised by systemd with restarts, limits and sandboxing, logging to the journal, configured by an environment file. Done this way it's as tidy as a container and starts faster.
Quick reference
| Piece | What |
|---|---|
| User | sudo useradd -r -s /usr/sbin/nologin -d /opt/myapp myapp |
| Code | /opt/myapp/app/ (git clone), venv in /opt/myapp/venv/ |
| Dependencies | requirements.txt pinned; venv/bin/pip install -r |
| Config | /etc/myapp/env (chmod 600), EnvironmentFile= in the unit |
| Unit | /etc/systemd/system/myapp.service with Restart=on-failure and sandboxing |
| Logs | journalctl -u myapp -f |
| Web app | gunicorn/uvicorn on 127.0.0.1:8000 behind Nginx (28) |
| Update | git pull && venv/bin/pip install -r requirements.txt && systemctl restart myapp |
1. A user and a home
sudo useradd -r -m -d /opt/myapp -s /usr/sbin/nologin myapp
sudo apt install python3-venv python3-pip git -y
A system user with no login shell: the app runs as it, owns only its folder, and can't be logged into.
2. Code and a virtual environment
sudo -u myapp -H bash -c '
cd /opt/myapp
git clone ssh://git@192.168.1.60:2222/john/myapp.git app
python3 -m venv venv
venv/bin/pip install --upgrade pip
venv/bin/pip install -r app/requirements.txt
'
The venv holds this app's packages, separate from the system's and every other app's. requirements.txt pinned (requests==2.32.3) means the same versions on reinstall (159).
Web app? Add gunicorn (Flask/Django) or uvicorn (FastAPI) to the requirements.
3. Config in an environment file
sudo mkdir -p /etc/myapp && sudo nano /etc/myapp/env
API_KEY=…
DATABASE_URL=postgresql://myapp:…@127.0.0.1:5432/myapp
NTFY_TOKEN=…
LOG_LEVEL=INFO
sudo chmod 600 /etc/myapp/env && sudo chown root:myapp /etc/myapp/env — the same secrets discipline as .env for compose (59). The app reads os.environ.
4. The systemd unit
sudo nano /etc/systemd/system/myapp.service
[Unit]
Description=My Python app
After=network-online.target postgresql.service
Wants=network-online.target
[Service]
Type=simple
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp/app
EnvironmentFile=/etc/myapp/env
ExecStart=/opt/myapp/venv/bin/python -u main.py
# web app instead:
# ExecStart=/opt/myapp/venv/bin/gunicorn -w 2 -b 127.0.0.1:8000 app:app
Restart=on-failure
RestartSec=5
TimeoutStopSec=20
# resources (68, but for the host)
MemoryMax=512M
CPUQuota=100%
# sandboxing
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/myapp/data
PrivateDevices=true
ProtectKernelTunables=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
LockPersonality=true
[Install]
WantedBy=multi-user.target
-u makes Python's output unbuffered so logs appear immediately. ProtectSystem=strict makes the whole filesystem read-only to the app except ReadWritePaths — most of a container's isolation, in six lines. For a serial port or GPIO, drop PrivateDevices and add the user to dialout/gpio.
sudo mkdir -p /opt/myapp/data && sudo chown myapp:myapp /opt/myapp/data
sudo systemctl daemon-reload
sudo systemctl enable --now myapp
systemctl status myapp
journalctl -u myapp -f
systemd-analyze security myapp scores the sandboxing and suggests more.
5. Logs
Print to stdout/stderr (or Python's logging to the stream handler): it's in the journal with timestamps, journalctl -u myapp --since today, rotated by journald (14), shipped to Loki by Alloy (155) with the unit label — no log files to rotate (67).
6. Web apps behind Nginx
gunicorn/uvicorn bound to 127.0.0.1:8000; an Nginx server block (28) proxies app.example.com to it with HTTPS. Static files served by Nginx directly from /opt/myapp/app/static (location /static/ { alias …; }). Health check: Uptime Kuma (35) on the public URL.
7. Update
sudo nano /usr/local/bin/myapp-update.sh
#!/bin/bash
set -euo pipefail
cd /opt/myapp/app
sudo -u myapp git pull --ff-only
sudo -u myapp ../venv/bin/pip install -q -r requirements.txt
sudo -u myapp ../venv/bin/python -m pytest -q || true # if you have tests
sudo systemctl restart myapp
sleep 3 && systemctl is-active myapp && echo "updated $(git rev-parse --short HEAD)"
Or let a Gitea webhook (45) via n8n (77) run it on push. Python version upgrades (after an LTS upgrade, 73): recreate the venv — rm -rf venv && python3 -m venv venv && pip install -r ….
8. Timers for one-shots
A script that should run every hour isn't a daemon: Type=oneshot plus a timer (69) with the same User=, EnvironmentFile= and sandboxing. The venv's python as ExecStart.
Container or host?
Host (this tutorial): small, your own code, hardware access, fastest start, no Docker dependency. Container (102): anything with system dependencies, anything you'll move between machines, anything that ships as an image. Both get a user, config in env, logs in one place, restarts and limits — the standards are the same.
Troubleshooting
"ModuleNotFoundError" when run by systemd but fine by hand. Wrong python — ExecStart must use the venv's python, and WorkingDirectory set.
"Read-only file system". ProtectSystem=strict without ReadWritePaths for where the app writes.
Starts, exits immediately, restarts forever. journalctl -u myapp -n 50; RestartSec and StartLimitBurst keep it from thrashing; fix the cause.
Environment variable not seen. EnvironmentFile path/permissions (readable by root is enough; systemd reads it), no export keyword, no quotes needed.
Wrapping up
A nologin user, a venv with pinned requirements, secrets in a 600 env file, a systemd unit with restarts, memory/CPU limits and ProtectSystem=strict, logs in the journal, Nginx in front if it's a web app, a one-script update. Everything a container gives you, for the small things that don't need one.
