# Headless ComfyUI: Run It as a Service, Use It from Anywhere URL: https://www.laserlloyd.com/projects/headless-comfyui-run-it-as-a-service-use-it-from-anywhere/ Published: 2026-07-11 Updated: 2026-09-16 Description: Run ComfyUI headless as a systemd service, script it over HTTP, and reach it securely from any device via a mesh VPN — workflows stay on the server. Reuse: free for personal use — full policy at https://www.laserlloyd.com/llms.txt Update 2026-09-16: I no longer run this ComfyUI setup on my own box — my day-to-day image generation now comes from a remote GPU rig instead. The instructions below are unchanged and still work if you want to run ComfyUI headlessly yourself. 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. Laptop browser full ComfyUI graph Phone browser same UI, same URL Scripts / bots HTTP API mesh VPN, HTTPS (or localhost) Always-on server: ComfyUI (systemd user unit) listens on 127.0.0.1 only; proxy terminates TLS everything saves here Server disk workflows/ · models/ · output/ — one library, zero scattering ## 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. — the same appliance model I use to put a chatbot on the public web without exposing the machine underneath it. 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. Related: Local AI Image Generation on AMD (ROCm): ComfyUI + Z-Image Turbo · Putting a Chatbot on the Public Web — On My Own Hardware · StudioForge: A GPU-Only LLM Server That Replaced LM Studio · A Small Office of AI Agents on One Mini PC: My OpenClaw Stack