For a Python AI worker on one VPS, systemd is simpler when the worker is a single script with pure-Python dependencies, and Docker is simpler when your dependencies, your deploys or your teammates make "works on my machine" a regular event. Either way, the bug that bites first is the same: whether the process gets SIGTERM and gets to finish its job.
I tested that on 2026-10-09, plus a few other differences, and verified a systemd unit with systemd-analyze. What I could not do is run systemd as PID 1, because my sandbox doesn't have it, so every systemd claim below is either from the systemd manual pages or from offline verification, not from a live service. The Docker parts all ran on Docker Engine 29.8.2 with python:3.12-slim.
The worker is a stand-in for a queue consumer calling an LLM: each job takes 3 seconds, and on SIGTERM it finishes the current job and exits 0. If you're unsure whether you need a worker process at all, read plain Python agent loop vs framework.
import os, signal, sys, time
stopping = False
def on_term(signum, frame):
global stopping
stopping = True
print(f"[{os.getpid()}] got signal {signum}, will exit after current job", flush=True)
signal.signal(signal.SIGTERM, on_term)
signal.signal(signal.SIGINT, on_term)
print(f"[{os.getpid()}] worker started", flush=True)
job = 0
while not stopping:
job += 1
print(f"[{os.getpid()}] job {job} started", flush=True)
time.sleep(3) # stand-in for an LLM call
print(f"[{os.getpid()}] job {job} finished", flush=True)
print(f"[{os.getpid()}] clean exit", flush=True)
sys.exit(0)
Which should you choose?
| Situation | Pick | Why |
|---|---|---|
| One worker script, pure-Python dependencies, one server | systemd | No image to build, journalctl for logs, nothing else to learn |
| Native libraries, CUDA, or several Python versions | Docker | The image carries the system libraries |
| You already run Compose for a proxy, database or n8n | Docker | One tool, one compose.yaml, one way to deploy |
| You want laptop and server to run the same artifact | Docker | Same image everywhere |
| Several people deploy, and rollbacks matter | Docker | Roll back by pointing at the previous image tag |
| Tiny VPS where every 100 MB counts | systemd | No daemon, no image layers. I didn't measure the difference |
| Strict control over what the process can touch | Either | Both can drop privileges; see below |
If you can't decide, and your worker is a single file with pip install dependencies, start with systemd. If you can't get a clean pip install on the server, start with Docker.
Does the worker shut down cleanly in Docker?
Only if your Dockerfile lets it. I built two images from the same worker.py, differing in one line, and ran docker stop 1.5 seconds after starting each, in the middle of job 1.
Exec form:
FROM python:3.12-slim
WORKDIR /app
COPY worker.py .
CMD ["python", "-u", "worker.py"]
docker stop took 1.7 s
exit code: 0
[1] worker started
[1] job 1 started
[1] got signal 15, will exit after current job
[1] job 1 finished
[1] clean exit
Shell form:
FROM python:3.12-slim
WORKDIR /app
COPY worker.py .
CMD python -u worker.py && echo done
docker stop took 10.2 s
exit code: 137
[7] worker started
[7] job 1 started
[7] job 1 finished
[7] job 2 started
[7] job 2 finished
[7] job 3 started
[7] job 3 finished
[7] job 4 started
The shell-form container never logged "got signal". It kept working through jobs 2 and 3 while Docker waited, then the daemon killed it during job 4 (exit 137 is 128 plus 9, SIGKILL). In my run the shell was PID 1 and the Python process was PID 7, so the signal landed on the shell, which did not forward it. Your shell and base image may behave differently, which is exactly why you test it.
For an AI worker this matters because a killed worker is a half-finished LLM call: tokens paid for, no result stored, and perhaps a task marked "in progress" forever. Rules I'd follow:
- Use exec-form
CMDorENTRYPOINT(a JSON array), not a shell string. If you need a shell script, end it withexec python .... - Handle SIGTERM in the worker and finish or requeue the current job.
- Set
stop_grace_periodin Compose (ordocker stop -t) longer than your slowest job. Docker's default is 10 seconds, which my test hit.
systemd has the same contract with different defaults. Its manual says the default KillSignal= is SIGTERM, and after the stop timeout it sends SIGKILL. TimeoutStopSec= is how long the service has to exit before that happens. So set TimeoutStopSec= the way you'd set stop_grace_period. Because systemd starts your python directly from ExecStart= with no shell in between, the shell-form trap doesn't exist unless you write ExecStart=/bin/sh -c ... yourself.
What does the systemd version look like?
Create a user, a virtualenv and an env file, then a unit. This is the unit I verified with systemd-analyze verify (clean, no output), after creating the user and paths it references:
[Unit]
Description=AI queue worker
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=60
StartLimitBurst=5
[Service]
Type=exec
User=aiworker
Group=aiworker
WorkingDirectory=/opt/aiworker
EnvironmentFile=/etc/aiworker/env
ExecStart=/opt/aiworker/.venv/bin/python -u worker.py
Restart=on-failure
RestartSec=5
TimeoutStopSec=60
KillSignal=SIGTERM
MemoryHigh=1536M
MemoryMax=2G
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
StateDirectory=aiworker
[Install]
WantedBy=multi-user.target
Save it as /etc/systemd/system/aiworker.service, then run sudo systemctl daemon-reload and sudo systemctl enable --now aiworker. Those commands are untested here. Follow logs with journalctl -u aiworker -f.
What the settings do, per the systemd manual pages I read today:
Type=execcounts the unit as started once the binary has actually run, and reports errors such as a missing binary; the manual says it is generally a better choice than the defaultsimple.Restart=defaults tono.on-failurerestarts on a non-zero exit, an unclean signal, a timeout or a watchdog timeout, and the manual recommends it for long-running services.RestartSec=defaults to 100 ms, so the 5 seconds here stops a crash loop from burning CPU and API calls.StartLimitIntervalSec=andStartLimitBurst=belong in[Unit], not[Service]. The unit manual says the defaults come fromDefaultStartLimitIntervalSec=andDefaultStartLimitBurst=in the manager configuration, which differ by distribution, so I set both explicitly: five starts in 60 seconds, then systemd stops trying and shows the unit as failed. That is what you want for a worker that crashes on a bad API key.MemoryHigh=throttles the unit when exceeded and does not kill it.MemoryMax=is the hard limit; the manual says the OOM killer is invoked inside the unit if usage can't be kept under it. The manual recommendsMemoryHighas the main control andMemoryMaxas the last line of defense.- Keep secrets out of the unit.
EnvironmentFile=points at a root-owned file,chmod 600, that holds your API keys. Anyone who can read the unit file would otherwise read the key.
How much does sandboxing add?
systemd-analyze security --offline=true scores a unit file without starting it. My unit above, with User=, NoNewPrivileges, PrivateTmp, ProtectSystem=strict and ProtectHome, scored an overall exposure of 8.3, "EXPOSED". Adding these lines took it to 1.7, "OK" (the tool also verified the file cleanly):
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
ProtectClock=true
ProtectHostname=true
LockPersonality=true
RestrictRealtime=true
RestrictSUIDSGID=true
RestrictNamespaces=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
CapabilityBoundingSet=
SystemCallArchitectures=native
SystemCallFilter=@system-service
UMask=0077
Treat that score as a checklist, not a guarantee. I never ran the hardened unit, so I can't promise your libraries survive SystemCallFilter=@system-service or RestrictAddressFamilies. Add the lines, start the service, read journalctl -u aiworker, and back off any option that breaks a real call. MemoryDenyWriteExecute= is the one the scanner still flags, and I left it off on purpose because some Python libraries need writable executable memory. Don't expect a score of zero for a worker that must reach the internet.
What does the Docker version look like?
A Compose service that mirrors the unit:
services:
worker:
image: w-exec
restart: unless-stopped
user: "10001:10001"
env_file: ./worker.env
stop_grace_period: 60s
mem_limit: 2g
read_only: true
tmpfs:
- /tmp
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
logging:
driver: local
options:
max-size: "10m"
max-file: "3"
Replace w-exec with your own image. I started this exact service on my test worker, so it runs with a non-root user, a read-only filesystem, all capabilities dropped and no new privileges, and the logs showed worker started and job 1 started. docker compose down then stopped it. Set logging deliberately: an unrotated log on a small disk is a classic slow failure, and I didn't check what your default driver does.
Port exposure is a Docker-specific concern that systemd doesn't have: if the worker publishes a port, Docker bypasses your host firewall rules for it. A queue worker normally publishes nothing. For the full story see the VPS hardening checklist. If the worker sits behind an API instead, the FastAPI LLM app with Docker and nginx post covers that layout.
Which one handles crashes and memory better?
They're closer than the internet suggests.
Crash loops. I ran a container that prints a line and exits 1 with restart: unless-stopped. After about 20 seconds Docker showed restarts=8 status=restarting, so it backs off between attempts and never gives up, which hides a permanently broken worker unless you watch. systemd with StartLimitBurst does give up and mark the unit failed, which is easier to alert on. Either way, wire an uptime check; the Vaultwarden and Uptime Kuma post shows a push monitor you can call from a job.
Memory. I capped a container at 64 MB (--memory 64m --memory-swap 64m) and ran a loop allocating 10 MB chunks. Docker reported:
OOMKilled=true exit=137
A memory cap turns a runaway agent loop or a huge batch into one dead container instead of a frozen server. systemd's MemoryMax= is the same idea, but I couldn't run it here. Combine the cap with a restart policy, and with a worker that checkpoints, so a kill loses at most one job. If your worker is long-running and stateful, durable execution with Temporal is a bigger answer to the same problem.
Logs. journalctl -u aiworker --since "1 hour ago" against docker compose logs --since 1h worker. Both are fine. The difference is where retention is configured: journald settings on the host, or the logging driver in Compose.
How do deploys compare?
systemd: git pull, pip install -r requirements.txt into the virtualenv, sudo systemctl restart aiworker. Simple, but the restart is a gap, and a bad dependency upgrade lives in the same virtualenv as the old one, so rollback is git checkout plus a reinstall. Docker: build or pull a tagged image and recreate the service. Rollback is the previous tag. For no-gap deploys of web-facing apps, the zero-downtime Compose post has a tested rolling script. For a background worker, a graceful SIGTERM and a queue that retries is usually enough, and no proxy is involved.
For the Python side of what the worker does while it waits on the API, async Python patterns for LLM production covers concurrency, and FastAPI with the Claude API covers the web service you might run next to it.
What I did not verify
- A running systemd service. My sandbox has no systemd as PID 1, so
systemctl,journalctl,Restart=,MemoryMax=andTimeoutStopSec=behavior comes from the manual pages andsystemd-analyze, not from a live unit. - The hardened unit at runtime, with a real LLM SDK.
- GPU workers, CUDA images and driver passthrough for either approach.
- Memory or CPU overhead of Docker against a bare process.
- Boot behavior of
restart: unless-stoppedafter a reboot. - A shell-form signal result on other base images or shells. Mine was Debian-based
python:3.12-slim.



