AI

How to Run an AI Image Generation Worker on Linux with systemd, Retries and Cost Tracking

Close-up of a Linux terminal prompt showing an Ubuntu user typing the sudo command
A small, well-behaved service beats a script left running in a tmux session. Photo via Unsplash.

Teams that start using image generation APIs usually begin with a script on a laptop. It works for a demo, but as soon as other people depend on it, the usual operational questions appear. Where does the API key live? What happens when the provider times out? Who notices when half the jobs fail overnight? How much did last week’s batch cost?

Original content from computingforgeeks.com - post 175340

This tutorial shows how to turn that script into a small, boring and reliable Linux service. We will build a worker that pulls prompts from a queue, calls an image generation API, saves every result locally, logs to journald, retries sensibly and keeps a running cost ledger. The examples use Ubuntu 24.04 and Python 3, but the approach works on any systemd-based distribution.

Six parts of a Linux image generation worker: a jobs table queue, a Python worker, a systemd unit, a storage directory, a cost ledger CSV and journald logs
The layout used in this tutorial.

Step 1: Create a service user and directories

The worker should never run as root or as your personal account. Create a dedicated system user with no login shell and give it ownership of a working directory.

sudo useradd --system --home /srv/imagegen --shell /usr/sbin/nologin imagegen
sudo mkdir -p /srv/imagegen/{out,app}
sudo chown -R imagegen:imagegen /srv/imagegen
sudo apt install -y python3-venv sqlite3
sudo -u imagegen python3 -m venv /srv/imagegen/venv
sudo -u imagegen /srv/imagegen/venv/bin/pip install requests

Step 2: Keep the API key out of the code

Store credentials in an environment file that only root and the service user can read. The worker reads the key and the provider endpoint from the environment, so nothing sensitive ends up in version control.

sudo tee /etc/imagegen.env >/dev/null <<'EOF'
API_KEY=replace-with-your-key
API_URL=replace-with-your-provider-endpoint
DAILY_BUDGET_USD=5.00
EOF
sudo chown root:imagegen /etc/imagegen.env
sudo chmod 640 /etc/imagegen.env

Step 3: A queue that is easy to inspect

For a single host, SQLite is a perfectly good queue. It survives restarts, needs no extra daemon and can be inspected with one command. If you later run several workers on different machines, swap it for Redis or a managed queue without changing the rest of the design.

sudo -u imagegen sqlite3 /srv/imagegen/jobs.db "
CREATE TABLE IF NOT EXISTS jobs(
  id INTEGER PRIMARY KEY, prompt TEXT, size TEXT DEFAULT '1K',
  status TEXT DEFAULT 'queued', attempts INTEGER DEFAULT 0,
  output TEXT, error TEXT, created TEXT DEFAULT CURRENT_TIMESTAMP);"

Adding a job is a single insert, which makes it easy to feed the queue from a cron job, a web form or another service.

Step 4: Choose a model and know its costs

Before writing the worker, decide which model it will call and what each image will cost. That number drives the budget check later. As an example, Microsoft’s MAI-Image-2.6 supports text-to-image generation, editing of an existing image and composition from up to five reference images, with output at 1K or 2K and aspect ratios from 1:4 to 4:1. Through the MAI-Image-2.6 API on APIMart, it is billed by actual tokens, with listed planning estimates of roughly $0.031 per 1K image and $0.070 per 2K image. Each request returns a single PNG, and failed tasks are refunded, which keeps the ledger logic simple: one successful job equals one image and one estimated charge.

BatchImagesSizeEstimated cost
Nightly thumbnails1001K≈ $3.10
Weekly blog headers202K≈ $1.40
Catalogue refresh5001K≈ $15.50
Test suite run251K≈ $0.78

Step 5: The worker

The worker loops forever: it checks the daily budget, claims one queued job, calls the API, saves the image and records the cost. Provider details vary, so the request body below is deliberately generic; adapt the field names to your provider’s documentation. Many image APIs return a task ID first and the image later, which is handled by the polling helper.

#!/srv/imagegen/venv/bin/python
import os, time, sqlite3, csv, datetime, requests

DB, OUT = "/srv/imagegen/jobs.db", "/srv/imagegen/out"
KEY, URL = os.environ["API_KEY"], os.environ["API_URL"]
BUDGET = float(os.environ.get("DAILY_BUDGET_USD", "5"))
PRICE = {"1K": 0.031, "2K": 0.070}
H = {"Authorization": f"Bearer {KEY}"}

def spent_today():
    today = datetime.date.today().isoformat()
    try:
        with open("/srv/imagegen/cost.csv") as f:
            return sum(float(r[3]) for r in csv.reader(f) if r[0].startswith(today))
    except FileNotFoundError:
        return 0.0

def poll(task_url):
    delay = 3
    for _ in range(40):
        r = requests.get(task_url, headers=H, timeout=30).json()
        if r.get("status") in ("succeeded", "failed"):
            return r
        time.sleep(delay); delay = min(delay * 2, 30)
    raise TimeoutError("task did not finish")

The main loop claims work with an update statement, so two workers on the same database never pick the same job.

while True:
    if spent_today() >= BUDGET:
        print("daily budget reached, sleeping"); time.sleep(600); continue
    db = sqlite3.connect(DB)
    row = db.execute("SELECT id,prompt,size,attempts FROM jobs "
                     "WHERE status='queued' ORDER BY id LIMIT 1").fetchone()
    if not row:
        time.sleep(10); continue
    jid, prompt, size, attempts = row
    db.execute("UPDATE jobs SET status='running', attempts=attempts+1 WHERE id=?", (jid,)); db.commit()
    try:
        r = requests.post(URL, headers=H, json={"prompt": prompt, "size": size}, timeout=60)
        r.raise_for_status()
        result = poll(r.json()["task_url"])
        img = requests.get(result["image_url"], timeout=120).content
        path = f"{OUT}/{jid}.png"; open(path, "wb").write(img)
        with open("/srv/imagegen/cost.csv", "a") as f:
            csv.writer(f).writerow([datetime.datetime.now().isoformat(), jid, size, PRICE[size]])
        db.execute("UPDATE jobs SET status='done', output=? WHERE id=?", (path, jid))
        print(f"job {jid} done")
    except Exception as e:
        state = "queued" if attempts + 1 < 3 else "failed"
        db.execute("UPDATE jobs SET status=?, error=? WHERE id=?", (state, str(e)[:500], jid))
        print(f"job {jid} error: {e}"); time.sleep(5 * (attempts + 1))
    db.commit(); db.close()

Note that the image is downloaded immediately. Result links from image APIs are usually temporary, so a worker that only stores the provider URL will eventually serve broken images.

Step 6: Run it under systemd

A systemd unit gives you automatic restarts, logging and basic sandboxing for free.

sudo tee /etc/systemd/system/imagegen.service >/dev/null <<'EOF'
[Unit]
Description=Image generation worker
After=network-online.target

[Service]
User=imagegen
EnvironmentFile=/etc/imagegen.env
ExecStart=/srv/imagegen/app/worker.py
Restart=on-failure
RestartSec=10
ProtectSystem=strict
ReadWritePaths=/srv/imagegen
NoNewPrivileges=true

[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now imagegen
journalctl -u imagegen -f

Step 7: Watch failures and spending

Two quick queries cover most day-to-day questions: how many jobs failed recently and how much has been spent today.

sqlite3 /srv/imagegen/jobs.db "SELECT status, COUNT(*) FROM jobs GROUP BY status;"
awk -F, -v d="$(date +%F)" '$1 ~ d {s+=$4} END {printf "%.2f USD\n", s}' /srv/imagegen/cost.csv

Wire the failure count into whatever monitoring you already use. A simple rule, such as alerting when more than ten percent of jobs fail within an hour, catches expired keys, provider incidents and malformed prompts quickly.

Five-point checklist before leaving the worker running: secrets in an EnvironmentFile, bounded retries, local copies of every image, a daily spend ceiling and alerts on failure rate
A short checklist before handing the worker over to production.

Troubleshooting common problems

Every job fails immediately. Check the journal first. An authentication error usually means the environment file was not loaded; run systemctl show imagegen -p EnvironmentFiles and confirm the file permissions allow the service user to read it.

Jobs stay in the running state. This happens when the worker is killed mid-job. Add a small startup step that resets jobs stuck in running for more than fifteen minutes back to queued.

Images look wrong after a model update. Keep a fixed set of ten test prompts and run them through the queue after any configuration change. Compare the new outputs with the previous set before switching production traffic.

The disk fills up. Add a systemd timer that moves files older than a week to object storage, and monitor free space on the output volume.

Where to go next

Once the basic worker is stable, the natural extensions are syncing the output directory to object storage with rclone, adding a small HTTP endpoint for submitting jobs, and keeping the model name and price table in the environment file so a model change does not require a code edit. None of these change the core idea: treat image generation like any other external dependency, with a queue, bounded retries, local copies and a ledger. That is what turns a clever script into a service your team can rely on.

Keep reading

Ollama Models Cheat Sheet: Best Models by VRAM, Tested (PDF) AI Ollama Models Cheat Sheet: Best Models by VRAM, Tested (PDF) Open Source LLM Comparison Table (2026) AI Open Source LLM Comparison Table (2026) OpenCode CLI Cheat Sheet: Commands, Sessions and Fixes (PDF) AI OpenCode CLI Cheat Sheet: Commands, Sessions and Fixes (PDF) Best Laptops for Local LLMs: RTX 50 vs 128GB Unified Memory AI Best Laptops for Local LLMs: RTX 50 vs 128GB Unified Memory Black Friday Mini PC Deals: Proxmox, Local AI and Home Assistant AI Black Friday Mini PC Deals: Proxmox, Local AI and Home Assistant Best AWS AI Practitioner and ML Engineer Books for AIF-C01 and MLA-C02 AI Best AWS AI Practitioner and ML Engineer Books for AIF-C01 and MLA-C02

Leave a Comment

Press ESC to close