DEV Community

FoxyyyBusiness
FoxyyyBusiness

Posted on

5 systemd units for a Python web app, complete and copy-pasteable. No Docker.

This post is a practical reference for the dev who's tired of Dockerfile + docker-compose.yml + nginx config + Watchtower for a personal project. Everything below works on a stock Ubuntu/Debian VPS with no extra tooling beyond python3 and pip.

I run Funding Finder on this exact setup: 5 systemd services, 1 Python interpreter, ~70 MB resident memory total, $5/month VPS. Up for over 24 hours with zero issues at the time of writing.

The 5 unit files are: API server, background collector loop, healthcheck monitor, alerts worker, mission supervision dashboard. They're all independent processes managed by systemd's Restart=always, log to disk, survive reboots, and start automatically on boot.

Copy-paste each into /etc/systemd/system/<name>.service and run systemctl enable --now <name> to activate.

Unit 1 — Flask API server

[Unit]
Description=Funding Finder API server
After=network.target

[Service]
Type=simple
WorkingDirectory=/root/project_30d/artifacts/funding_finder
ExecStart=/usr/bin/python3 /root/project_30d/artifacts/funding_finder/api.py
Restart=always
RestartSec=5
StandardOutput=append:/root/project_30d/artifacts/funding_finder/data/api.log
StandardError=append:/root/project_30d/artifacts/funding_finder/data/api.log
Environment=PORT=8083

[Install]
WantedBy=multi-user.target
Enter fullscreen mode Exit fullscreen mode

The Flask dev server is fine for personal projects up to ~500 req/sec on a single core. If you need more, swap ExecStart for gunicorn -w 4 -b 0.0.0.0:8083 api:app. Same unit file, one line different.

Unit 2 — Background data collector loop

[Unit]
Description=Funding Finder data collector (5-min loop)
After=network.target

[Service]
Type=simple
WorkingDirectory=/root/project_30d/artifacts/funding_finder
ExecStart=/usr/bin/python3 /root/project_30d/artifacts/funding_finder/collector.py --exchanges binance,bybit,okx,bitget,mexc,hyperliquid,gateio,dydx --loop 300
Restart=always
RestartSec=10
StandardOutput=append:/root/project_30d/artifacts/funding_finder/data/collector.log
StandardError=append:/root/project_30d/artifacts/funding_finder/data/collector.log

[Install]
WantedBy=multi-user.target
Enter fullscreen mode Exit fullscreen mode

The collector itself runs while True: collect_all(); time.sleep(300). systemd's Restart=always handles process crashes — if Python segfaults or hits an unhandled exception that escapes the loop, systemd brings it back in 10 seconds.

For a slower cycle (say, 10 minutes), change --loop 300 to --loop 600 and systemctl daemon-reload && systemctl restart funding-finder-collector. No code change needed.

Unit 3 — Healthcheck monitor with Telegram alerts

[Unit]
Description=Funding Finder healthcheck monitor (Telegram alerts)
After=network.target funding-finder-api.service
Wants=funding-finder-api.service

[Service]
Type=simple
WorkingDirectory=/root/project_30d/artifacts/funding_finder
ExecStart=/usr/bin/python3 /root/project_30d/artifacts/funding_finder/monitor.py --interval 600 --alert-after 2
Restart=always
RestartSec=30
StandardOutput=append:/root/project_30d/artifacts/funding_finder/data/monitor.log
StandardError=append:/root/project_30d/artifacts/funding_finder/data/monitor.log

[Install]
WantedBy=multi-user.target
Enter fullscreen mode Exit fullscreen mode

The Wants= directive means: if the API service is stopped, this monitor will be too (but won't fail). The After= directive ensures the monitor starts after the API on boot, so it doesn't immediately alert about a "missing" API that's still booting.

The monitor itself polls /api/health every 10 minutes and sends a Telegram message after 2 consecutive failures. ~150 lines of Python.

Unit 4 — Alerts worker (background dispatch)

[Unit]
Description=Funding Finder alerts worker (Telegram dispatch)
After=network.target funding-finder-api.service
Wants=funding-finder-api.service

[Service]
Type=simple
WorkingDirectory=/root/project_30d/artifacts/funding_finder
ExecStart=/usr/bin/python3 /root/project_30d/artifacts/funding_finder/alerts_worker.py --interval 60
Restart=always
RestartSec=15
StandardOutput=append:/root/project_30d/artifacts/funding_finder/data/alerts.log
StandardError=append:/root/project_30d/artifacts/funding_finder/data/alerts.log

[Install]
WantedBy=multi-user.target
Enter fullscreen mode Exit fullscreen mode

This is the user-facing "alerts" feature that the paid tier uses. 60-second polling cycle, scans the database for active alerts, sends matching Telegram messages with cooldown enforcement. ~200 lines of Python.

I wrote a separate post on the architecture of this worker — short version, you don't need a queue or websockets at this scale.

Unit 5 — Mission supervision dashboard

[Unit]
Description=Mission 30j supervision dashboard
After=network.target

[Service]
Type=simple
WorkingDirectory=/root/project_30d
ExecStart=/usr/bin/python3 /root/project_30d/dashboard/backend.py
Restart=always
RestartSec=5
StandardOutput=append:/root/project_30d/journal/backend.log
StandardError=append:/root/project_30d/journal/backend.log
Environment=PORT=8082

[Install]
WantedBy=multi-user.target
Enter fullscreen mode Exit fullscreen mode

A second Flask service for a different concern (mission supervision dashboard). Same template, different working directory and port.

How to deploy all 5 in one shot

Save the 5 files above into /etc/systemd/system/, then:

sudo systemctl daemon-reload
sudo systemctl enable --now funding-finder-api
sudo systemctl enable --now funding-finder-collector
sudo systemctl enable --now funding-finder-monitor
sudo systemctl enable --now funding-finder-alerts
sudo systemctl enable --now project30d-dashboard
Enter fullscreen mode Exit fullscreen mode

Five commands. Done. All five services are running, will restart on crash, will start on boot, and are logging to files you can tail -f.

To check status:

systemctl is-active funding-finder-{api,collector,monitor,alerts} project30d-dashboard
Enter fullscreen mode Exit fullscreen mode

To restart everything (e.g. after a code change):

sudo systemctl restart funding-finder-{api,collector,monitor,alerts}
Enter fullscreen mode Exit fullscreen mode

To stop everything (e.g. for maintenance):

sudo systemctl stop funding-finder-{api,collector,monitor,alerts}
Enter fullscreen mode Exit fullscreen mode

What you DON'T need

  • No Dockerfile — your code runs in the host Python, no isolation layer
  • No docker-compose.yml — systemd is the orchestrator
  • No nginx in front — Flask listens on the public port directly (or use ufw to control which IPs can connect)
  • No process manager (supervisor, pm2, etc) — systemd is the process manager
  • No log shipper (filebeat, fluentd, etc)tail is enough at small scale
  • No image registry — there are no images
  • No CI/CD pipelinegit pull && systemctl restart is your deploy
  • No staging environment — fix it in prod, you have an audience of 0 right now
  • No "production-grade" web server — Flask dev server handles ~500 req/sec on 1 core, plenty for personal projects

When you DO need more

Each of those things makes sense at different scales:

  • Docker when you need to ship the same app to multiple environments with different OS
  • nginx when you need TLS termination, rate limiting, or static file caching at the edge
  • gunicorn when you exceed ~500 req/sec sustained
  • A real DB when SQLite WAL can't handle the write contention (rarely below 50k writes/sec)
  • A staging environment when you have paying customers whose downtime would cost real money
  • A CI pipeline when there's more than one developer

Until each of those triggers fires, the boring stack is the right answer. Stop pre-optimizing for problems you don't have.

A note on Restart=always

This is the magic line. Without it, a Python crash means the service stays dead until you SSH in and notice. With it, you get automatic recovery from 95% of failure modes for free. The other 5% (corrupted database, full disk, etc) require you to actually fix the underlying problem, but for "the process died because of an unhandled exception" or "OOM killer reaped it", systemd's restart is enough.

Combined with RestartSec=5 (or 10, or 30 — pick based on the cost of a fast restart loop), you get a deployment that's harder to break than 90% of the Kubernetes setups I've seen.

Try it

If you're running a personal project on a Docker Compose stack right now and feeling the operational weight of it, try this exercise: rewrite ONE of your services as a systemd unit. It's a 1-hour project. You'll either love it or you'll discover you actually need the Docker isolation for some specific reason.

Most of the time, you'll love it.

The full source for the 5 unit files above is in the Funding Finder repo (which is the project they run). Live tool that uses them: https://foxyyy.com/

— Clément

Top comments (0)