Headless ComfyUI: Run It as a Service, Use It from Anywhere

Posted
July 11, 2026
Updated
July 11, 2026
By
Jacob Lloyd — written with AI assistance, post-project
Read time
8 min read

In plain terms: My image-generation computer runs ComfyUI all the time in the background, with no monitor and nobody logged into a desktop. I can start it, check on it, or ask it for an image from a one-line command, and I can open its full interface from my laptop or phone over a private encrypted connection. A small plugin I built makes sure every workflow I save from any device lands on that one computer, so nothing gets scattered.

In the last article I got ComfyUI + Z-Image Turbo generating a 1024px image in ~27 seconds on an AMD mini PC. This is the follow-up: turning that install into an always-on appliance — no desktop session, no terminal babysitting — that any device in the house can use, while everything it produces and every workflow it saves stays on that one box.

tl;dr

  • What it is: ComfyUI running headless under a systemd user service, controlled by a small CLI, scripted over its HTTP API, and reached from other devices through a mesh VPN with HTTPS.
  • What it costs: free. The VPN tier that does this (Tailscale personal) is also free.
  • What you need: a working ComfyUI install and about an evening.
  • What you end up with: comfyctl generate "..." from any shell, the full UI on your laptop or phone, and a workflow library that lives in one folder on the server no matter which device you saved from.

What you end up with

Day to day, "using the image box" looks like one of three things, none of which involve sitting at it:

comfyctl status     # server health + systemd state + newest output file
comfyctl generate "a foggy harbor at dawn, cinematic"
comfyctl logs 100

…or a script on some other machine POSTing a prompt to the HTTP API, or the full node-graph UI open in a browser tab on my laptop across the house — over HTTPS, through the VPN, with saves landing on the server's disk.

Why headless

The web UI is how you design a workflow. It's a terrible way to run one for the 200th time. Once a workflow is dialed in, what you actually want is an appliance: a box that's always on, always ready, and answers requests from whatever asks — a shell one-liner, a cron job, a chatbot that needs a fresh avatar at 3 a.m.

Headless here doesn't mean "no UI ever." The server has no desktop session and no browser of its own; the UI still exists, served over HTTP to whatever device wants it. The server does the work, everything else is just a screen.

The systemd user service

The service pattern is the same as the setup article, so I'll keep it short. A systemd user unit at ~/.config/systemd/user/comfyui.service:

[Unit]
Description=ComfyUI server
After=network-online.target

[Service]
ExecStart=%h/comfy/start-comfyui.sh
WorkingDirectory=%h/comfy/ComfyUI
EnvironmentFile=-%h/comfy/comfy.env
Restart=on-failure
RestartSec=5
TimeoutStartSec=120

[Install]
WantedBy=default.target
systemctl --user daemon-reload
systemctl --user enable --now comfyui.service
loginctl enable-linger $USER     # keep user services running with nobody logged in

That last line is the headless-specific part people miss: without lingering, user units stop when your last session ends — which on a headless box is "immediately." The EnvironmentFile keeps the port and launch flags in one editable place (comfy.env), so other tools can read the same value instead of hardcoding 8188.

A comfyctl-style control script

Raw systemctl works, but a thin wrapper makes the box feel like a product. Mine is ~100 lines of bash named comfyctl:

comfyctl start      # systemctl start + poll /system_stats until healthy
comfyctl stop
comfyctl restart
comfyctl status     # health + unit state + newest output PNG
comfyctl generate "<prompt>" [--seed N --steps 8 --width 1024 --out PATH]
comfyctl logs [N]   # journalctl --user -u comfyui.service -n N

The details that make it pleasant rather than just short:

  • Health means the API answers, not that the process exists. curl -sf http://127.0.0.1:$PORT/system_stats in a loop with a 60-second budget. systemd saying "active" only means Python started; a model server isn't up until it answers HTTP.
  • It reads the port from the same env file the service uses, so changing the port in one place can't strand the tool.
  • generate auto-starts the server. If the health check fails it starts the unit, waits for health, then submits. Callers never need to know whether the box was awake.
  • status prints the newest file in output/ — the answer to "did my last generation actually work" without opening anything.

Driving the HTTP API from scripts

Everything the UI does goes through the same HTTP API, and the core loop is three endpoints. A workflow is just JSON (export via Save (API format) in the UI); a script loads it, swaps in the prompt and a fresh seed, and:

import json, time, urllib.request

SERVER = "http://127.0.0.1:8188"
graph = json.load(open("workflow_api.json"))
graph["6"]["inputs"]["text"] = "a foggy harbor at dawn, cinematic"   # your prompt node id

# 1. queue it
req = urllib.request.Request(f"{SERVER}/prompt",
    data=json.dumps({"prompt": graph}).encode(),
    headers={"Content-Type": "application/json"})
pid = json.load(urllib.request.urlopen(req))["prompt_id"]

# 2. poll history until done
while True:
    hist = json.load(urllib.request.urlopen(f"{SERVER}/history/{pid}"))
    if pid in hist: break
    time.sleep(1)

# 3. fetch the image
img = hist[pid]["outputs"]["9"]["images"][0]           # your SaveImage node id
url = f"{SERVER}/view?filename={img['filename']}&subfolder={img['subfolder']}&type={img['type']}"
open("result.png", "wb").write(urllib.request.urlopen(url).read())

Standard library only — no ComfyUI client package, no API key. This is the pattern behind comfyctl generate, and it's the piece that turns the box into infrastructure: anything that can make an HTTP request can now make images.

The Workbench plugin: your workflow library lives on the box

Here's the problem that showed up the first week of remote use. ComfyUI's stock Export saves the workflow JSON through the browser's download mechanism — onto whatever device you're browsing from. Use the UI from three devices and your workflow library is scattered across three Downloads folders, none of them the machine that actually runs the workflows.

So I built a small custom node ("Workbench") that fixes the direction of gravity. It's a normal custom_nodes plugin: the Python side registers a few routes on ComfyUI's own web server, the JS side adds a sidebar tab. It does two jobs:

1. Working-folder save/open

The sidebar's Save button POSTs the current graph to the plugin, which writes it into ComfyUI's canonical user/default/workflows folder on the server — sanitized filename, confined to that folder, atomic write (temp file + rename), and a rolling .bak.json of whatever it overwrites. Open lists that same folder, newest first.

The key point: the UI runs in a remote browser, but saving goes to the server's local disk. Save from the laptop, open from the phone, run from a script — one library, one folder, on the machine with the GPU. (On my box that folder is symlinked to a plain ~/comfy/workflows directory, so it's also trivially backed up.) Because the frontend only makes origin-relative calls to the server it was loaded from, it behaves identically on localhost and through a proxy — no configuration per device.

2. Missing-model scan + allowlisted downloads

The other remote-use pain: you open a workflow and it wants a model file the box doesn't have. Downloading 12GB to your phone and re-uploading it is obviously wrong. The plugin scans the loaded workflow against every registered model folder, lists what's missing, and downloads it server-side, directly into the right models/<folder> — streamed to a .part file, resumable via HTTP Range, optional sha256 verification, with progress pushed to the UI over the websocket.

Because "the browser can tell the server to download arbitrary URLs to disk" is a scary sentence, it's fenced in:

  • HTTPS only, and the hostname must match a config.json allowlist (e.g. huggingface.co, civitai.com, wildcards like *.hf.co) — and every redirect hop is re-validated against the same list, since redirect-to-anywhere is the classic allowlist bypass.
  • Filenames are reduced to a sanitized basename and must end in an allowlisted extension (.safetensors etc.); the target must be a registered model folder — no path traversal.
  • Existing files are never overwritten, there's a per-download size cap (30GB default), and downloads queue one at a time.

Secure remote access: mesh VPN + HTTPS

ComfyUI on this box listens on 127.0.0.1 only — hardcoded, not a flag. It has no login page, so it must never be exposed to the internet, and honestly not to the raw LAN either. The clean answer is a mesh VPN like Tailscale: every device gets a private encrypted address, nothing is reachable from outside your account, and its serve feature acts as a local HTTPS reverse proxy:

tailscale serve --bg --https=8443 http://127.0.0.1:8188

Now https://<your-machine>.<your-tailnet>.ts.net:8443 serves the full UI, with a real (automatically provisioned) TLS certificate, to your devices only. ComfyUI itself still only ever hears from localhost — the proxy is the sole doorway.

HTTPS isn't optional politeness here. Browsers restrict more and more (clipboard, some worker/websocket behavior) on plain HTTP from non-localhost origins, and you're shipping your prompts and images across the network. The mesh VPN gives you a real certificate without owning a domain or opening a port. One practical note: connect by the machine's DNS name, not its raw VPN IP — the TLS certificate is issued for the name, so the IP will fail the certificate check.

Gotchas

  • 403s that only happen remotely are origin checks, not auth. ComfyUI's server middleware validates Origin/Host and friends on state-changing requests; some proxy setups forward headers that don't match what it expects (I hit exactly this wiring up another tool — a Sec-Fetch-Site-based 403). Fix the proxy's forwarded headers, or scope ComfyUI's CORS settings (--enable-cors-header) to your one trusted origin — never *.
  • Use the VPN hostname, not the IP. HTTPS via the mesh VPN is certificate-per-name; hitting the raw address gives TLS errors that look like the server is broken.
  • Custom-node frontends must be origin-relative. Any hardcoded http://127.0.0.1:8188/... in a plugin's JS works on the box and silently breaks through the proxy. Use ComfyUI's api.fetchApi() and relative paths.
  • Websockets through a proxy can drop. Progress events ride the websocket; behind a proxy it occasionally dies quietly. Workbench polls as a fallback for exactly this reason — design for it.
  • loginctl enable-linger or your "service" dies at logout.
  • "Unit is active" ≠ "server is up."
  • Keep the download allowlist tight.
  • Never port-forward this to the internet. No auth + arbitrary workflow execution + (with a downloader) writes to disk. Mesh VPN or nothing.

Where the first article ended with a computer that can generate images, this one ends with something better: a quiet box on a shelf that every device and script I own can use — and that keeps everything it makes in one place.


← More AI & Local LLM