Introduction
As it turns out, it is not as trivial to put ComfyUI behind a subdirectory on your domain like servername.yourdomain.com/comfyui as one might think. Internal API calls of ComfyUI lead to a situation where some requests get resolved while others do not, or are outright rejected with a 405 Method Not Allowed status,[1] even when proxying to just the root directory under servername.yourdomain.com.
So this short article will show you how to configure your nginx setup so it works with ComfyUI’s current behavior. It then shows how to adjust ComfyUI’s workflow-saving logic so it works correctly when using an upstream reverse proxy.
The nginx configuration
First off, here is the configuration itself:
server {
listen 80;
listen [::]:80;
rewrite ^(.*) https://$host$1 permanent;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name servername.yourdomain.com;
client_max_body_size 0;
ssl_certificate /root/ssl/servername.yourdomain.com/fullchain.pem;
ssl_certificate_key /root/ssl/servername.yourdomain.com/privkey.pem;
# ===== Root directory ===============
location / {
return 301 /comfyui/;
}
# ===== ComfyUI ===============
location = /comfyui {
return 301 /comfyui/;
}
location /comfyui/ {
if ($request_uri ~* "^/comfyui/(.*)") {
set $path_resource $1;
}
proxy_http_version 1.1;
proxy_buffering off;
proxy_request_buffering off;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
send_timeout 86400s;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://127.0.0.1:8188/$path_resource;
}
}Let’s walk through the individual parts of this configuration.
The first server block handles the HTTP-to-HTTPS redirect. Any plain HTTP request coming in on port 80 gets permanently redirected (301) to its HTTPS equivalent, ensuring all traffic is encrypted.
Inside the second block, the ssl_certificate and ssl_certificate_key directives point to your TLS certificate and private key. In this example they are sourced from Let’s Encrypt via Certbot, but the paths can of course be adjusted to wherever your certificates live.
The root location / block simply issues a permanent redirect to /comfyui/, so anyone visiting the bare domain gets sent to the right place. The location = /comfyui block (note the exact match) handles the case where someone navigates to the path without a trailing slash, again redirecting them to /comfyui/.
The main location /comfyui/ block is where the actual proxying happens, and it contains the most important piece of this whole setup: the $request_uri block.[2] The if directive uses a RegEx to capture everything after /comfyui/ from the raw request URI, including any query string, and stores it in $path_resource. This variable is then used directly in the proxy_pass directive to forward the request to ComfyUI’s local port.
Using $request_uri here is necessary. In my tests, this approach led to the least weird behaviour from ComfyUI. The fact that it captures the query string as part of the path is a side effect we will address in the next section.
Fixing the ComfyUI workflow saving logic
As mentioned above, the $request_uri RegEx captures the entire raw URI, query string included. This means that when a user saves a workflow, ComfyUI’s backend receives the query parameters baked into the file path rather than as proper query parameters. In practice, saving a workflow called test.json would create a file literally named test.json?overwrite=false&full_info=true on the filesystem. On top of that, the GUI would not refresh its workflow list after saving, because the full_info parameter, which signals the backend to return the richer response the frontend needs to trigger that update, was never being parsed correctly.
The fix requires three separate changes to the app/user_manager.py file in the ComfyUI source code. The highlighted sections indicate lines that I’ve added here.
The first change strips the query string from the file path in get_user_data_path():
def get_user_data_path(request, check_exists = False, param = "file"):
file = request.match_info.get(param, None)
if not file:
return web.Response(status=400)
# Handle reverse proxy cases where query parameters may be embedded in the path
if "?" in file:
file = file.split("?")[0]
path = self.get_request_user_filepath(request, file)
if not path:
return web.Response(status=403)
if check_exists and not os.path.exists(path):
return web.Response(status=404)
return pathThis ensures that whenever the file parameter contains an embedded query string, only the actual filename is used for path resolution. Without this, the query string would end up as a literal part of the filename on the filesystem.
The second change recovers the query parameters in post_userdata() so that overwrite and full_info are parsed correctly even when they arrived embedded in the path:
path = get_user_data_path(request)
if not isinstance(path, str):
return path
# Handle reverse proxy cases where query parameters may be embedded in the path
query_string = request.rel_url.query_string.decode("UTF-8") if request.rel_url.query_string else ""
if not query_string:
file_param = request.match_info.get("file", "")
if "?" in file_param:
query_string = file_param.split("?", 1)[1]
parsed_query = parse.parse_qs(query_string)
overwrite = parsed_query.get("overwrite", ["true"])[0] != "false"
full_info = parsed_query.get("full_info", ["false"])[0].lower() == "true"
if not overwrite and os.path.exists(path):
return web.Response(status=409, text="File already exists")If the request arrives with a proper query string, it is used as is. If not, the code falls back to extracting it from the embedded file parameter. This restores correct behaviour for both the overwrite check and the full_info response, which is what triggers the GUI to refresh its workflow list after saving.
The third change applies the same query string recovery logic to move_userdata(). This function deals with two path parameters rather than one, a source and a destination, so the fallback checks both file and dest for an embedded query string:
dest = get_user_data_path(request, check_exists=False, param="dest")
if not isinstance(dest, str):
return dest
# Handle reverse proxy cases where query parameters may be embedded in the path
query_string = request.rel_url.query_string.decode("UTF-8") if request.rel_url.query_string else ""
if not query_string:
file_param = request.match_info.get("file", "")
dest_param = request.match_info.get("dest", "")
if "?" in file_param:
query_string = file_param.split("?", 1)[1]
elif "?" in dest_param:
query_string = dest_param.split("?", 1)[1]
parsed_query = parse.parse_qs(query_string)
overwrite = parsed_query.get("overwrite", ["true"])[0] != "false"
full_info = parsed_query.get("full_info", ["false"])[0].lower() == "true"
if not overwrite and os.path.exists(dest):
return web.Response(status=409, text="File already exists")Final remarks and footnotes
With both the nginx configuration and the three patches to user_manager.py in place, ComfyUI now behaves correctly behind a reverse proxy while being routed to a subdirectory, including workflow saving, overwrite handling, and GUI refresh. It is a bit more involved than one might hope, but once it is set up it works reliably. Hopefully this saves you the troubleshooting time and energy it took me to figure this out. You’re welcome.
[1] https://github.com/Comfy-Org/ComfyUI/issues/9664
[2] https://github.com/Comfy-Org/ComfyUI/issues/8325