websocketd — full documentation (generated, do not edit) Source: https://websocketd.com/docs/ Curated index: https://websocketd.com/llms.txt ======================================================================== Install https://websocketd.com/docs/start/install/ ======================================================================== websocketd is a single binary with no runtime and no configuration file. Installing it means putting that one file somewhere on your PATH. Download a release Every release publishes a binary for each supported platform on the releases page . Open the latest release, pick the archive matching your operating system and processor architecture, and download it. Builds are published for Linux on 32-bit and 64-bit x86 and on 32-bit and 64-bit ARM, for macOS on Intel and Apple Silicon, and for Windows on 32-bit and 64-bit x86. Unpack the archive and move the binary onto your PATH: unzip websocketd-*.zip sudo mv websocketd /usr/local/bin/ Each archive also contains the README, the license, and the changelog. You do not need any of them to run the program. Debian and Red Hat packages are published alongside the archives if you would rather install through your system package manager. Build from source You need Go 1.21 or newer. git clone https://github.com/joewalnes/websocketd.git cd websocketd go build That is the entire build. It leaves a websocketd binary in the current directory, which you can move onto your PATH the same way. There is no Makefile to run and no dependencies to fetch by hand; Go's module system fetches the one library websocketd uses. Check that it worked websocketd --version That prints one line: the release number, the Go toolchain the binary was built with, and your platform. If your shell says command not found, the binary is not on your PATH. Check that the directory you moved it to is listed in echo $PATH. Now go and use it: the tutorial takes about fifteen minutes and ends with a browser talking to a script you wrote. ======================================================================== Start https://websocketd.com/docs/start/ ======================================================================== Two pages. The first gets a websocketd binary onto your PATH. The second is a single lesson: you write a five-line shell script, wrap it, and end up with a web page in your own browser receiving what that script prints. You need three things before starting: A terminal. A text editor. A web browser on the same machine. You do not need to know anything about the WebSocket protocol, and you do not need Go unless you choose to build from source. Budget about fifteen minutes, most of it typing. ======================================================================== Tutorial https://websocketd.com/docs/start/tutorial/ ======================================================================== By the end of this page a web page in your browser will count to five, one number a second, driven by a shell script on your machine. Here is what websocketd does between the two. It runs your script and connects it to the browser: every line the script prints to stdout is sent to the browser as one WebSocket message, at the moment it is printed, and anything the browser sends back arrives on the script's stdin. The script contains no networking code and never knows a browser is there. You need websocketd on your PATH. If websocketd --version does not print a version, work through the install first. 1. Write a script Make a directory to work in, and go into it: mkdir counter cd counter Create a file called count.sh containing exactly this: #!/bin/bash for COUNT in 1 2 3 4 5; do echo $COUNT sleep 1 done Make it executable: chmod +x count.sh Run it on its own first, with no websocketd involved: ./count.sh 1 2 3 4 5 The numbers appear one a second. Nothing about this script knows what a WebSocket is, and nothing about it is going to change. 2. Wrap it with websocketd websocketd --port=8080 --sameorigin ./count.sh The server starts and prints two lines: Sun, 06 Sep 2026 19:41:15 -0700 | INFO | server | | Serving using application : ./count.sh Sun, 06 Sep 2026 19:41:15 -0700 | INFO | server | | Starting WebSocket server : ws://example-host.local:8080/ Your second line will name your own machine rather than example-host.local, which stands in for it throughout this page. --sameorigin allows a WebSocket connection only from a page served by this same host and port. Leave it out and websocketd accepts one from any page in any browser that can reach the port, and prints a long warning at startup saying so. Keep it on for the rest of this tutorial. The security model covers the other policies and when to reach for them. Now open http://localhost:8080/ in your browser. You get this: 404 page not found This server speaks WebSocket and nothing else so far, and you have not given it a web page to hand out. Step 4 does that. The server agrees with the browser, in its own log: Sun, 06 Sep 2026 19:41:16 -0700 | ACCESS | http | url:'http://localhost:8080/' | NOT FOUND The banner says a different hostname than you typed The startup line above says ws://example-host.local:8080/, but you opened http://localhost:8080/. Both reach the same server. websocketd looks up your machine's own network name to build that banner. It never prints example-host.local: that name is this page's stand-in for whatever your machine is called, and yours will read something else entirely. This tutorial says localhost everywhere, because localhost always resolves to your own machine no matter what network you are on. When the banner and this page disagree about the hostname, they are not in conflict. Use localhost. Flags go before the command, always websocketd --port=8080 ./count.sh works. websocketd ./count.sh --port=8080 does not, and it does not tell you so. Go's flag parser stops looking for flags at the first argument that is not one, so everything after ./count.sh is handed to count.sh as an argument instead: Sun, 06 Sep 2026 19:36:12 -0700 | INFO | server | | Serving using application : ./count.sh --port=9999 Sun, 06 Sep 2026 19:36:12 -0700 | INFO | server | | Starting WebSocket server : ws://example-host.local:80/ Look at the first line. If a flag you typed shows up after your script name on the Serving using application line, it was never read as a flag, and the server fell back to its default of port 80. Move the flag before the command. 3. Drive it from the dev console You have a WebSocket server, but nothing to talk to it with yet. websocketd ships with a page for exactly this. Stop the server with Ctrl+C and start it again with --devconsole: websocketd --port=8080 --sameorigin --devconsole ./count.sh There is a third startup line now: Sun, 06 Sep 2026 19:38:40 -0700 | INFO | server | | Serving using application : ./count.sh Sun, 06 Sep 2026 19:38:40 -0700 | INFO | server | | Starting WebSocket server : ws://example-host.local:8080/ Sun, 06 Sep 2026 19:38:40 -0700 | INFO | server | | Developer console enabled : http://example-host.local:8080/ Open http://localhost:8080/ again. Instead of the 404 you get the console: a connect button, a box to send messages from, and a running log of everything your script prints. Connect, and watch 1 through 5 arrive one a second. Each number is a separate WebSocket message: count.sh wrote a line to stdout, and websocketd sent that line on as it appeared. The server logs the session as it opens: Sun, 06 Sep 2026 19:38:40 -0700 | ACCESS | http | url:'http://localhost:8080/' | DEVCONSOLE Sun, 06 Sep 2026 19:38:40 -0700 | ACCESS | session | url:'http://localhost:8080/' id:'4f43ed0dfd946de9' remote:'127.0.0.1' command:'./count.sh' origin:'http://localhost:8080' | CONNECT Every connection you make starts its own fresh copy of count.sh. Open a second browser tab and it counts from 1 again, independently, in its own process. Numbers arriving one at a time is not free You saw the numbers stream because count.sh is a bash script, and bash writes each echo out immediately. Most other languages do not. Python, Ruby, PHP, C, and others switch to holding output in a buffer when stdout is a pipe rather than a terminal, and a pipe is exactly what your program gets here. The symptom is all five numbers landing at once when the script exits, instead of one a second. The fix is one flag or one line per language, and it is on that language's page: Python , Ruby , PHP , C , Node.js . The reason it happens is in output buffering . Do not add --staticdir to this command --devconsole serves its own page at /, so it cannot share the server with --staticdir or --cgidir, which also want to serve /. Combining them is not ignored or quietly resolved. The server refuses to start and exits with code 4: Sun, 06 Sep 2026 19:38:22 -0700 | FATAL | server | | Invalid parameters: --devconsole cannot be used with --staticdir. Pick one. These are two ways to run the server, not two flags to combine. Use --devconsole while you are poking at the script. Use --staticdir, as you are about to, once you have a front end of your own. 4. Serve your own page Stop the server with Ctrl+C. Your HTML goes in a directory of its own. websocketd serves everything in the directory you point --staticdir at, and count.sh is not something you want handed out: mkdir public Create public/count.html: <!DOCTYPE html> <title>count</title> <pre id="log"></pre> <script> const log = document.getElementById('log'); const ws = new WebSocket('ws://' + location.host + '/'); ws.onopen = () => { log.textContent += 'CONNECT\n'; }; ws.onmessage = (e) => { log.textContent += e.data + '\n'; }; ws.onclose = () => { log.textContent += 'DISCONNECT\n'; }; </script> Start the server pointing at that directory: websocketd --port=8080 --sameorigin --staticdir=public ./count.sh Four startup lines this time, including an http:// one: Sun, 06 Sep 2026 19:39:14 -0700 | INFO | server | | Serving using application : ./count.sh Sun, 06 Sep 2026 19:39:14 -0700 | INFO | server | | Serving static content from : public Sun, 06 Sep 2026 19:39:14 -0700 | INFO | server | | Starting WebSocket server : ws://example-host.local:8080/ Sun, 06 Sep 2026 19:39:14 -0700 | INFO | server | | Serving CGI or static files : http://example-host.local:8080/ Open http://localhost:8080/count.html. The page shows CONNECT, then 1 through 5 one a second, then DISCONNECT when the script finishes and its process exits. The server logs the same story: Sun, 06 Sep 2026 19:39:14 -0700 | ACCESS | http | url:'http://localhost:8080/count.html' | STATIC Sun, 06 Sep 2026 19:39:15 -0700 | ACCESS | session | url:'http://localhost:8080/' id:'12d9958a57d99ae6' remote:'127.0.0.1' command:'./count.sh' origin:'http://localhost:8080' | CONNECT Sun, 06 Sep 2026 19:39:21 -0700 | ACCESS | session | url:'http://localhost:8080/' id:'12d9958a57d99ae6' remote:'127.0.0.1' command:'./count.sh' origin:'http://localhost:8080' pid:'1965' | DISCONNECT That is the whole thing working. Open the page through the server, not off the disk Do not double-click count.html, and do not open it as a file:// URL straight from the disk. A page loaded that way has no origin. Browsers send Origin: null for it, --sameorigin rejects the upgrade, and the page sits there having never connected: Sun, 06 Sep 2026 19:38:58 -0700 | ACCESS | session | url:'http://localhost:8080/' id:'4f6bfb8c6295f37b' remote:'127.0.0.1' command:'./count.sh' origin:'file:' | Same origin policy mismatch Sun, 06 Sep 2026 19:38:58 -0700 | ACCESS | session | url:'http://localhost:8080/' id:'4f6bfb8c6295f37b' remote:'127.0.0.1' command:'./count.sh' origin:'file:' | Unable to Upgrade: websocket: request origin not allowed by Upgrader.CheckOrigin The browser gets a 403 Forbidden for the upgrade, which is visible only in its developer tools. On the page itself nothing happens at all. Always reach the page at http://localhost:8080/count.html, so the page and the socket share an origin. Never type the port into the JavaScript The snippet above builds the socket URL from location.host, which is the host and port the page itself was loaded from. Change --port and it follows, with nothing to keep in sync. Had it said new WebSocket('ws://localhost:8080/') and you were running on 8081, the page would load perfectly and simply never connect: no server log line, no error on the page, nothing. That silent mismatch is easy to miss and slow to diagnose, and location.host removes it entirely. What you know now Any program that reads stdin and writes stdout is a WebSocket backend. count.sh was never modified. Every connection gets its own process. Two tabs are two independent runs of your script. Flags go before the command name. If a flag appears on the Serving using application line, it was swallowed as an argument. Without an origin policy the server accepts connections from anywhere and prints a warning saying so. --sameorigin is the right answer while you are developing. The startup banner names your machine's hostname. localhost reaches the same server. --devconsole and --staticdir are two ways to run the server, chosen per run. Together they exit with code 4. Serve your page over http:// and derive the socket URL from location.host. A file:// page is rejected, and a hardcoded port fails silently. Where to go next The process model covers what one process per connection means once your script does something real, and what it rules out. Output buffering explains why the output stops appearing when you rewrite count.sh in another language. Pass data into your script gets query-string and per-connection data into your program. Debug a script gets more out of the dev console than the connect button. CLI flags lists everything websocketd takes, with its default. ======================================================================== Language-specific fixes https://websocketd.com/docs/how-to/languages/ ======================================================================== Nearly every report of "my script sends nothing" has the same cause: your language's runtime holds output in a private buffer when standard output is a pipe rather than a terminal, and websocketd always gives it a pipe. The fix is one flag or one line, and it is different in every language. Find yours below. Each page opens with the change to make. If you want to know why the runtime behaves this way, read output buffering afterwards; you do not need any of it to apply the fix. ======================================================================== Stream output from a Python script https://websocketd.com/docs/how-to/languages/python/ ======================================================================== Run your script with python3 -u. The -u flag tells Python to write each line to stdout the moment your code produces it, rather than collecting lines in a buffer and writing them out in one block. websocketd --port=8080 python3 -u ./count.py import time for count in range(1, 6): print(count) time.sleep(0.5) Connect a client and the five numbers arrive half a second apart. Remove the -u and nothing arrives until the script exits, at which point all five appear together. The output is identical either way. Only its timing changes. Output buffering explains why the runtime does this. If you do not control the command line Sometimes you cannot add -u. The script may be launched by its shebang line, or through --dir, or by a wrapper you did not write. In that case flush from inside the script. Pass flush=True to each print you want delivered immediately: import time for count in range(1, 6): print(count, flush=True) time.sleep(0.5) Or call sys.stdout.flush() after the writes that matter, which also works if you are producing output with sys.stdout.write rather than print: import sys import time for count in range(1, 6): print(count) sys.stdout.flush() time.sleep(0.5) Both are equivalent to -u for the lines you apply them to. -u is safer, because it cannot be forgotten on the one print that mattered. Setting PYTHONUNBUFFERED takes two steps Python also stops buffering when the environment variable PYTHONUNBUFFERED holds any non-empty value. Exporting it in your shell is not enough on its own. websocketd builds a fresh environment for every process it launches. It copies across only the variables named by --passenv, so a variable you did not name never reaches your script. Export the variable and name it: export PYTHONUNBUFFERED=1 websocketd --port=8080 --passenv=PATH,PYTHONUNBUFFERED ./count.py --passenv replaces the default list rather than adding to it. The default is PATH,LD_LIBRARY_PATH on Linux and PATH,DYLD_LIBRARY_PATH on macOS, so writing --passenv=PYTHONUNBUFFERED on its own hands your script an environment with no PATH in it at all. Name every variable you need, including PATH, on the one flag. A script that never launches another program may not notice a missing PATH. One that does will: any lookup by bare command name fails with a file-not-found error, which looks nothing like an environment problem from the inside. Prefer -u unless you specifically need the environment variable, for example because the same script has to behave the same way under a container runtime that sets it. Next Output buffering is the reason all of this is necessary. Pass data into your script covers the rest of what --passenv is for. Environment variables lists everything websocketd sets for your process. Debug a script shows the timing of what arrives. ======================================================================== Stream output from a Ruby script https://websocketd.com/docs/how-to/languages/ruby/ ======================================================================== Set STDOUT.sync = true once, at the top of your script, before you print anything. Ruby then writes every puts and print straight to stdout instead of collecting them in a buffer. websocketd --port=8080 ruby ./count.rb STDOUT.sync = true (1..5).each do |count| puts count sleep(0.5) end Connect a client and the five numbers arrive half a second apart. Remove the sync line and all five arrive together when the script exits. Output buffering explains why. There is no per-call flush to remember afterwards. Setting sync changes the stream for the life of the process, so every later write goes out immediately without any further work. If you cannot edit the top of the script Ruby has no command-line switch that turns buffering off, so the change has to happen in Ruby code. Where the script is not yours to edit, put the one line in a small wrapper that loads it: STDOUT.sync = true load File.expand_path('vendored_script.rb', __dir__) Wrap the wrapper, not the original: websocketd --port=8080 ruby ./wrapper.rb Flushing selectively instead If you have a reason to keep buffering on, STDOUT.flush empties the buffer at a point you choose: (1..5).each do |count| puts count STDOUT.flush sleep(0.5) end This is more code and one more thing to forget. Use it only when you write enough output for the buffering to pay for itself. Next Output buffering is the reason all of this is necessary. Message framing covers the other requirement: each message needs a trailing newline, which puts adds for you and print does not. Debug a script shows the timing of what arrives. ======================================================================== Read input in a Node.js script https://websocketd.com/docs/how-to/languages/nodejs/ ======================================================================== Read stdin with the readline module and handle each line as it arrives. Node is the exception among the languages here: its output needs no flush call, and what goes wrong is the reading side. websocketd --port=8080 node ./echo.js const readline = require('node:readline'); const rl = readline.createInterface({ input: process.stdin }); rl.on('line', (line) => { process.stdout.write(`echo: ${line}\n`); }); rl.on('close', () => { process.exit(0); }); Send hello world from a client and echo: hello world comes back immediately. Every message you send fires line once, with the trailing newline already stripped. Why waiting for end-of-file produces nothing The pattern that fails is any variation on "read all of stdin, then start work". It looks like this, and under websocketd it produces no output at all, ever: // Do not do this. function readAll() { return new Promise((resolve) => { let data = ''; process.stdin.on('data', (chunk) => { data += chunk; }); process.stdin.on('end', () => resolve(data)); }); } (async function () { const input = await readAll(); process.stdout.write('got: ' + input.trim() + '\n'); })(); The end event fires when stdin reaches end-of-file, meaning the writing end of the pipe has closed and no more bytes will ever arrive. Under echo 'hello' | node script.js that happens the instant echo finishes, so the script works. Under websocketd it happens only when the client disconnects, because until then the connection is still open and more messages may still come. The promise never resolves, the script never reaches its first write, and the browser sees silence. Treat stdin as a stream that stays open for the life of the connection and is consumed a line at a time. That is what readline gives you. Do not read raw data events yourself websocketd writes one line to your script's stdin per message it receives, but a pipe makes no promise that one data event corresponds to one line. A single event can carry a partial line, several whole lines at once, or a line split across two events, depending on timing and how the operating system happened to fill the buffer. Reassembling those correctly is exactly what readline already does. Attach a raw data handler only when you need bytes before a newline has arrived, which under the default line framing is rare. Output needs no flush call process.stdout.write does not accumulate output the way Python, Ruby, PHP and C do. There is no block buffer to empty, and no Node equivalent of Python's -u to set: let n = 1; const timer = setInterval(() => { process.stdout.write(`${n}\n`); if (n++ === 5) { clearInterval(timer); process.exit(0); } }, 500); Those five numbers arrive half a second apart with nothing else done to the script. Output buffering explains what the other runtimes do; Node does not do it. Remember the trailing \n regardless. websocketd sends a WebSocket message when it reads a newline, so a write without one is held, not sent. console.log appends the newline for you; process.stdout.write does not. Your script's stdin closes before any signal When a client disconnects, the first thing websocketd does is close your script's stdin. Only after that does it escalate to signals. So rl.on('close', ...) fires before any SIGINT or SIGTERM handler would, and it is the earlier and more portable place to put your cleanup. Keep a signal handler if you have one, but do not rely on it as the first notification. A short script may exit before any signal is sent. The full sequence is in process lifecycle . Next Process lifecycle has the teardown sequence and its timings. Message framing explains the newline rule in both directions. Process model explains why your script has exactly one client and never needs to tell them apart. ======================================================================== Stream output from a PHP script https://websocketd.com/docs/how-to/languages/php/ ======================================================================== Call flush() after each line you want delivered immediately. If your script or its framework has PHP's own output buffering turned on, call ob_flush() first to empty that layer, then flush() to push the bytes out of the process. websocketd --port=8080 php ./count.php <?php for ($count = 1; $count <= 5; $count++) { echo $count . "\n"; flush(); usleep(500000); } Connect a client and the five numbers arrive half a second apart. Output buffering explains why a runtime holds output back when stdout is a pipe. The two layers, and which call empties which PHP can hold your output in two separate places, and they need different calls. The first is PHP's own output buffer, an internal store that ob_start() turns on and that some frameworks and some php.ini settings turn on for you. While it is active, echo writes into that store and nothing leaves the process. ob_flush() empties it. The second is the buffer belonging to the layer underneath, which holds bytes that have left PHP's output buffer but not yet reached the pipe. flush() empties that one. Calling both, in that order, covers either arrangement: <?php echo "ready\n"; ob_flush(); flush(); ob_flush() emits a notice if no output buffer is active, so guard it when you are not sure: <?php echo "ready\n"; if (ob_get_level() > 0) { ob_flush(); } flush(); If you want no buffering at all rather than a flush at every write, turn it off once at the top instead: <?php while (ob_get_level() > 0) { ob_end_flush(); } Open the file with <?php, never <? A file that begins with <? rather than <?php is using the short open tag, an abbreviated form that PHP only recognises when the short_open_tag setting is on. That setting is off in a default installation. When it is off, PHP does not treat <? as the start of code. It treats the whole file as literal text and prints your source back out. Your program never runs. From websocketd's side nothing looks wrong. Your script produced output, websocketd forwarded it, and the client received it. The symptom is a browser showing PHP source code, or showing output that never changes, which is easy to mistake for a buffering problem when in fact no PHP executed at all. Always open the file with the full tag: <?php echo "this actually runs\n"; Check which configuration is in play with php -i | grep short_open_tag. The command-line PHP binary often reads a different php.ini from the one your web server uses, so a script that works under a web server can still fail here. Each line needs a trailing newline websocketd sends a WebSocket message when it reads a newline, so echo without one leaves the line held rather than sent. echo $count . "\n" above supplies it. This is a separate requirement from flushing, and either one alone produces the same silence in the browser. Message framing has the rule. Next Output buffering is the reason flushing is necessary. Message framing covers the newline requirement. Debug a script shows how to see what is really arriving, which distinguishes a short-tag failure from a buffering one immediately. ======================================================================== Stream output from a C program https://websocketd.com/docs/how-to/languages/c/ ======================================================================== Call setbuf(stdout, NULL) as the first statement of main, before any output. That turns off the C standard library's buffering on stdout for the rest of the process, so every printf goes straight down the pipe. cc -o count count.c websocketd --port=8080 ./count #include <stdio.h> #include <unistd.h> int main(void) { setbuf(stdout, NULL); for (int i = 1; i <= 5; i++) { printf("%d\n", i); usleep(500000); } return 0; } Connect a client and the five numbers arrive half a second apart. Remove the setbuf line and all five arrive together when the program exits. Output buffering explains why. Call it before you write anything. setbuf must be applied to a stream that has had no input or output performed on it yet, so the top of main is the place for it. setvbuf, if you prefer the explicit form setvbuf does the same job with the mode spelled out, and returns a value you can check: setvbuf(stdout, NULL, _IONBF, 0); _IONBF means unbuffered. The two other modes name the behaviour this page is working around: _IOLBF is line-buffered, which flushes on each newline, and _IOFBF is fully buffered, which is what your program gets by default when stdout is a pipe. _IOLBF is a reasonable middle choice under websocketd, since a WebSocket message boundary is a newline anyway: setvbuf(stdout, NULL, _IOLBF, 0); fflush, if you want to keep the buffer Where the program produces a lot of output and you want to keep buffering for the bulk of it, leave the stream alone and call fflush(stdout) after the writes that must go out now: for (int i = 1; i <= 5; i++) { printf("%d\n", i); fflush(stdout); usleep(500000); } This streams identically. The cost is one call to remember at every site that matters. Forget one and that message silently never arrives. For a program you cannot recompile If the binary is not yours, wrap it in stdbuf, which sets the buffering mode through the loader before the program starts: websocketd --port=8080 stdbuf -oL ./legacy-binary -oL makes stdout line-buffered. This works only for programs that use the C standard library's stdio and have not set their own buffering explicitly. stdbuf ships with GNU coreutils, so it is present on typical Linux systems and not on macOS by default. Next Output buffering is the reason all of this is necessary. Message framing covers the trailing newline that printf("%d\n", i) supplies here. Debug a script shows the timing of what arrives. ======================================================================== Run a .bat, .cmd, or PowerShell script on Windows https://websocketd.com/docs/how-to/languages/windows-scripts/ ======================================================================== Name the interpreter explicitly and pass your script to it as an argument. Do not point websocketd at the script file on its own. For a .bat or .cmd batch file, the interpreter is cmd.exe, and /c tells it to run the file and then exit: websocketd.exe --port=8080 cmd.exe /c C:\scripts\count.bat For a .ps1 PowerShell script, the interpreter is powershell.exe, and -File tells it to run the file: websocketd.exe --port=8080 powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\scripts\count.ps1 Use pwsh.exe in place of powershell.exe for PowerShell 7 and later. -ExecutionPolicy Bypass is needed because a default PowerShell installation refuses to run unsigned local script files. -NoProfile skips the user's profile script, which would otherwise run first and can write its own text to stdout, mixing startup noise into your script's messages. A batch file to try it with: @echo off for /L %%i in (1,1,5) do ( echo %%i timeout /t 1 /nobreak > nul ) Why the interpreter has to be named On Unix, a script file can say which interpreter runs it. The first line holds a shebang, written #! followed by a path, as in #!/usr/bin/env python3. The Unix kernel reads those two characters when it is asked to execute the file, and launches the named interpreter with the file as its argument. Windows has no equivalent. The shebang is a kernel feature, and the Windows kernel does not implement it. A #! line at the top of a file on Windows is a comment, or a syntax error, depending on the language. Windows decides what runs a file from its extension instead, through a registry association. That association is a property of the machine's configuration, not of the file, and it is not guaranteed to be present or correct in the account and session that websocketd is running under. This is a recurring cause of a script that runs perfectly when double-clicked and fails to start under websocketd. Naming cmd.exe or powershell.exe yourself removes the question. You are no longer asking Windows to work out what to run; you are telling it. Give the interpreter a full path websocketd resolves the command you give it by searching PATH, the list of directories the operating system looks in for an executable. Two things make that search less predictable on Windows than on Unix. The first is that names differ between installations. Node.js, for example, installs its executable as node.exe under some methods, while documentation and scripts written elsewhere refer to it as nodejs. A name that works on a colleague's machine can find nothing on yours, and websocketd reports only that it could not locate the command. The second is that a different program with the same name can be earlier in PATH and win the search. Then websocketd starts successfully and runs the wrong program, which is the harder failure to diagnose because nothing reports an error. Give the full path to the interpreter and neither can happen: websocketd.exe --port=8080 "C:\Program Files\nodejs\node.exe" C:\scripts\echo.js Your script gets no signal on disconnect On Unix, websocketd tears a process down in stages when a client disconnects. It closes the process's stdin, then sends SIGINT, then SIGTERM, then SIGKILL, pausing between each. A signal is a Unix notification that a process can catch and act on, so those middle stages give a well-behaved program a window to save state and exit cleanly. Windows has no equivalent mechanism for one process to send SIGINT or SIGTERM to another. websocketd still attempts both stages, and both fail; you will see the failures logged as errors. The process is then terminated forcibly. Your script gets no window in which to clean up, and there is no fix for this, because there is no Windows facility to use instead. Closing stdin is the one part of the sequence that works everywhere, and it happens first. If your script needs to do anything on disconnect, have it watch for end-of-file on stdin rather than for a signal. That approach behaves the same on every platform. Windows also has no process groups in the Unix sense, so websocketd cannot sweep up processes that your script started. Anything your script launches is left running unless your script stops it itself. Next Platform support is the factual list of what differs on Windows. Process lifecycle has the full teardown sequence and its timings, and what a long-running program should do about it. Output buffering applies on Windows exactly as it does elsewhere. If your script runs but sends nothing, that page and the language page for whatever the script is written in are the place to look. ======================================================================== Serve behind nginx https://websocketd.com/docs/how-to/deploy/nginx/ ======================================================================== Put nginx on the public port and websocketd on a local one, and forward the upgrade with an explicit Upgrade and Connection header pair. nginx does not pass those two through on its own, so a location block that works for ordinary HTTP fails the WebSocket handshake. Run websocketd bound to loopback: websocketd --port=8080 --address=127.0.0.1 --origin=https://example.com /opt/myapp/myscript.sh Then configure nginx: http { map $http_upgrade $connection_upgrade { default upgrade; '' close; } server { listen 80; server_name example.com; location / { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_read_timeout 3600s; proxy_send_timeout 3600s; } } } Reload nginx, and ws://example.com/ reaches websocketd. Why each of those lines is there proxy_http_version 1.1 sets the protocol nginx speaks to the backend. The default is HTTP/1.0, which has no Upgrade mechanism at all. Leave this out and the handshake cannot succeed no matter what headers you set. proxy_set_header Upgrade and proxy_set_header Connection restore the two hop-by-hop headers nginx strips before forwarding a request. websocketd answers with 101 Switching Protocols only when it sees both. The map block computes the right Connection value per request. Send Connection: upgrade only when the client asked to upgrade; everything else on the same location (a --cgidir script, a --staticdir file) needs Connection: close. One location then serves both kinds of traffic. Timeouts, and how they interact with --pingms proxy_read_timeout and proxy_send_timeout both default to 60 seconds. Neither one is a connection lifetime. Each is an idle timer: proxy_read_timeout counts from the last byte nginx read from websocketd, and proxy_send_timeout from the last byte nginx wrote to it. A WebSocket connection where nobody types for a minute trips them, and nginx closes it while both ends still believe it is healthy. There are two ways to stop that, and they combine. Raise the timeouts, as above, above the longest silence you expect. Or make the connection never fall silent. Start websocketd with --pingms set below the proxy timeout: websocketd --port=8080 --address=127.0.0.1 --pingms=30000 /opt/myapp/myscript.sh websocketd then sends a WebSocket ping frame every 30 seconds. The ping is traffic from the backend, so it resets nginx's read timer; the client's pong is traffic toward the backend, so it resets the send timer. --pingms also gives websocketd its own liveness check: it sets a read deadline of twice the ping interval and drops a connection that misses its pongs for that long. Pick a ping interval below the proxy timeout with room to spare, so a single lost frame does not trip it. Half the timeout, as above, is a reasonable starting point. Terminating TLS at nginx Add the usual listen 443 ssl server block with ssl_certificate and ssl_certificate_key. The location block does not change, and websocketd keeps speaking plain HTTP on loopback. See serving over wss:// for the origin-policy trap that this arrangement creates. Next Coexist with another web server if nginx is already serving an application on this domain. The exposure checklist before this goes on a public address. Run it under systemd so websocketd starts at boot. The security model for what the origin policy protects. ======================================================================== Deploy websocketd https://websocketd.com/docs/how-to/deploy/ ======================================================================== Pick the page that matches your environment. Each one gives you a working configuration first. The proxy pages solve the same problem three ways: your public server terminates the connection, and the WebSocket upgrade has to survive the trip to websocketd. Choose the one you already run. ======================================================================== Serve behind Apache https://websocketd.com/docs/how-to/deploy/apache/ ======================================================================== Apache carries the upgrade for you through mod_proxy_wstunnel. You name a ws:// backend and the module handles the handshake, so unlike nginx there are no Upgrade or Connection headers to restore by hand. Run websocketd bound to loopback: websocketd --port=8080 --address=127.0.0.1 --origin=https://example.com /opt/myapp/myscript.sh Then configure Apache: LoadModule proxy_module modules/mod_proxy.so LoadModule proxy_http_module modules/mod_proxy_http.so LoadModule proxy_wstunnel_module modules/mod_proxy_wstunnel.so <VirtualHost *:80> ServerName example.com ProxyPass "/ws/" "ws://127.0.0.1:8080/" ProxyPassReverse "/ws/" "ws://127.0.0.1:8080/" ProxyTimeout 3600 </VirtualHost> Restart Apache, and ws://example.com/ws/ reaches websocketd. The modules Three must be loaded. mod_proxy is the proxy core and does nothing on its own. mod_proxy_wstunnel is what understands a ws:// or wss:// backend URL and hands the connection over as a bidirectional tunnel once the upgrade succeeds. mod_proxy_http is needed for any ordinary HTTP you also proxy to the same websocketd, such as a --staticdir file or a --cgidir script. On Debian and Ubuntu, enable them with a2enmod proxy proxy_http proxy_wstunnel instead of writing LoadModule lines by hand. Serving WebSocket and plain HTTP on the same path ProxyPass commits a path to one backend scheme. If the same URL prefix must answer both a WebSocket upgrade and an ordinary HTTP request, route on the Upgrade header with mod_rewrite: LoadModule rewrite_module modules/mod_rewrite.so RewriteEngine On RewriteCond %{HTTP:Upgrade} =websocket [NC] RewriteRule ^/?(.*) "ws://127.0.0.1:8080/$1" [P,L] RewriteCond %{HTTP:Upgrade} !=websocket [NC] RewriteRule ^/?(.*) "http://127.0.0.1:8080/$1" [P,L] Both rules point at the same websocketd on the same port. Only the scheme differs, and the scheme is what tells Apache whether to tunnel the connection or proxy it as ordinary HTTP. [P] sends the request through the proxy; [L] stops rewrite processing for that request. Timeouts, and how they interact with --pingms ProxyTimeout is an idle timer on the backend connection, and it applies to a tunnelled WebSocket the same as to an HTTP response. Unset, it inherits the server-wide Timeout, which is 60 seconds in a stock configuration. An idle WebSocket connection dies at that mark. Raise ProxyTimeout above the longest silence you expect, as above. Or start websocketd with --pingms set below it so the connection is never idle: websocketd --port=8080 --address=127.0.0.1 --pingms=30000 /opt/myapp/myscript.sh websocketd sends a ping frame at that interval, which is traffic on the tunnel, and drops a connection whose pongs stop arriving for twice the interval. The Host header Apache forwards By default Apache sends the backend a Host header naming the backend itself, 127.0.0.1:8080 in the configuration above, not example.com. ProxyPreserveHost On forwards the client's Host instead. This matters if you set an origin policy on websocketd. See serving over wss:// for what --sameorigin compares and why it is the wrong flag behind a proxy. Next Coexist with another web server if Apache already serves an application on this domain. The exposure checklist before this goes on a public address. Run it under systemd so websocketd starts at boot. The security model for what the origin policy protects. ======================================================================== Serve behind HAProxy https://websocketd.com/docs/how-to/deploy/haproxy/ ======================================================================== HAProxy needs no WebSocket-specific directive. In HTTP mode the upgrade handshake is an ordinary request and response, and HAProxy switches the connection to a tunnel once it sees the 101. What you must set explicitly are the timeouts, because HAProxy's defaults assume short HTTP requests. Run websocketd bound to loopback: websocketd --port=8080 --address=127.0.0.1 --origin=https://example.com /opt/myapp/myscript.sh Then configure HAProxy: defaults mode http timeout connect 5s timeout client 30s timeout server 30s timeout tunnel 3600s frontend public bind *:80 acl is_upgrade hdr(Upgrade) -i websocket use_backend websocketd if is_upgrade default_backend app backend websocketd server ws1 127.0.0.1:8080 check backend app server app1 127.0.0.1:3000 check Reload HAProxy, and ws://example.com/ reaches websocketd. The timeouts timeout tunnel is the one that matters, and the easiest to miss. Once the upgrade succeeds the connection stops being a request and response and becomes a raw bidirectional tunnel. HAProxy then stops applying timeout client and timeout server to it and applies timeout tunnel instead. If you never set timeout tunnel, HAProxy falls back to the client and server timeouts, and a healthy but idle WebSocket connection is cut at whichever is shorter. Set timeout tunnel above the longest silence you expect. Or keep the connection from ever falling silent by starting websocketd with --pingms below the tunnel timeout: websocketd --port=8080 --address=127.0.0.1 --pingms=30000 /opt/myapp/myscript.sh websocketd then sends a WebSocket ping at that interval, which is traffic through the tunnel. It also sets its own read deadline at twice the interval and drops a client whose pongs stop arriving. timeout connect covers reaching websocketd in the first place and can stay short. Loopback either connects immediately or refuses immediately. Routing by Upgrade versus routing by path The acl is_upgrade above sends anything carrying Upgrade: websocket to websocketd and everything else to another application. That works when the two share a hostname but not a path. If you would rather split on the URL, replace the ACL with a path match: acl is_ws path_beg /ws/ use_backend websocketd if is_ws websocketd receives the path as sent, /ws/... included. That matters when you run it with --dir, where the path selects which script runs. See coexisting with another web server . Terminating TLS at HAProxy Add bind *:443 ssl crt /etc/haproxy/certs/example.com.pem to the frontend. The ACLs, the backends and the timeouts are unchanged, and websocketd keeps speaking plain HTTP on loopback. See serving over wss:// for the origin-policy trap that arrangement creates. Next The exposure checklist before this goes on a public address. Run it under systemd so websocketd starts at boot. The security model for what the origin policy protects. ======================================================================== Start at boot with systemd https://websocketd.com/docs/how-to/deploy/systemd/ ======================================================================== websocketd runs in the foreground and never forks into the background, so a plain Type=simple unit is all it needs. A complete one: # /etc/systemd/system/websocketd.service [Unit] Description=websocketd wrapping myapp After=network-online.target Wants=network-online.target [Service] Type=simple User=websocketd Group=websocketd WorkingDirectory=/opt/myapp ExecStart=/usr/local/bin/websocketd \ --port=8080 \ --address=127.0.0.1 \ --origin=https://example.com \ /opt/myapp/myscript.sh Restart=on-failure RestartSec=2 NoNewPrivileges=yes PrivateTmp=yes [Install] WantedBy=multi-user.target Create the account it runs as, install the unit, and start it: sudo useradd --system --shell /usr/sbin/nologin --home-dir /opt/myapp websocketd sudo systemctl daemon-reload sudo systemctl enable --now websocketd Check it came up, and follow its output: systemctl status websocketd journalctl -u websocketd -f Where the logs go websocketd writes its access log and its diagnostics to stdout and stderr, and never to a file. systemd captures both into the journal, so journalctl -u websocketd is the log. There is no log path to configure and no log rotation to set up; the journal's own retention settings apply. The startup banner about origin policy goes to stderr, so it lands in the journal on every restart until you give websocketd an origin policy. The --origin in the unit above is what silences it. Running unprivileged, and what that costs you User=websocketd runs the service as an unprivileged account. Do this. A websocketd endpoint is a way to run a program, so the account it runs as is the blast radius if a wrapped script has a flaw. The consequence is that an unprivileged process cannot bind a port below 1024. --port=80 and --port=443 will fail with a permission error, and the unit will restart-loop. Two ways around it, in order of preference, and one that does not work: Put a reverse proxy on 80 and 443. The proxy already runs as root long enough to bind them, and you want it there anyway for TLS. Keep websocketd on a high port bound to 127.0.0.1 as the unit above does. See nginx , Apache or HAProxy . Grant the capability instead of the account. Add AmbientCapabilities=CAP_NET_BIND_SERVICE to the [Service] section. systemd then lets this one unprivileged process bind low ports without giving it any other privilege. Change --port to 80 or 443. Drop the reverse proxy only if you also do not want TLS, which websocketd can serve but a proxy serves better. Let systemd own the socket. A separate websocketd.socket unit with ListenStream=80 will not work: websocketd opens its own listener and does not accept a pre-opened file descriptor from systemd. Do not solve it by running as root. Restart behaviour Restart=on-failure restarts websocketd when it exits non-zero or is killed by a signal, and leaves it stopped after a clean systemctl stop. That is what you want for a server. It also means a configuration mistake restart-loops rather than failing loudly. websocketd validates its flags before binding and exits with a non-zero code and a message on stderr, so journalctl -u websocketd will show the same complaint repeating. See exit codes for what each one means. RestartSec=2 keeps that loop slow enough to read. Passing environment variables to the script A Environment= line in the unit sets a variable for the websocketd process, not for the scripts it runs. websocketd starts each script with a controlled environment and passes through only what you name: Environment=MYAPP_TOKEN=s3cret ExecStart=/usr/local/bin/websocketd --port=8080 --passenv=MYAPP_TOKEN /opt/myapp/myscript.sh See the CGI environment for what a script receives, and environment variables for the full list. Next Serve behind nginx to put TLS and a public port in front of this. The exposure checklist before this goes on a public address. Exit codes when the unit will not stay up. ======================================================================== Run in a container https://websocketd.com/docs/how-to/deploy/docker/ ======================================================================== websocketd never allocates a pseudo-terminal. A pseudo-terminal, or pty, is the kernel object that makes a program believe it is talking to a real terminal. websocketd connects your program's stdin and stdout to ordinary pipes instead, in a container or anywhere else. So a program that needs a terminal does not work through websocketd, and no Docker flag changes that. That rules out docker run -it, screen, watch, anything that calls isatty() and behaves differently, and anything that draws with cursor positioning or expects job control. This is a permanent design property, not a gap; see design decisions . Everything below assumes a non-interactive program. A working image FROM golang:1.24-alpine AS build WORKDIR /src RUN apk add --no-cache git \ && git clone --depth 1 --branch v0.5.0 https://github.com/joewalnes/websocketd.git . \ && go build -o /out/websocketd . FROM alpine:3.21 RUN adduser -S -D -H websocketd COPY --from=build /out/websocketd /usr/local/bin/websocketd COPY myscript.sh /app/myscript.sh RUN chmod +x /app/myscript.sh USER websocketd EXPOSE 8080 ENTRYPOINT ["websocketd", "--port=8080", "--address=0.0.0.0", "--origin=https://example.com", "/app/myscript.sh"] Build it and run it: docker build -t myapp-ws . docker run --init -p 8080:8080 myapp-ws ws://localhost:8080/ now reaches your script. --address=0.0.0.0 Bind all interfaces inside the container. This is websocketd's default, so the flag only documents the intent. The opposite is a trap: --address=127.0.0.1 inside a container binds the container's own loopback, which is a different loopback from the host's. The -p 8080:8080 mapping forwards to the container's external interface, so a loopback-bound websocketd is unreachable through it. The other deployment pages say the reverse: bind loopback and front it with a proxy. Inside a container the network namespace is already the isolation boundary. --init, and why websocketd should not be PID 1 docker run --init puts a small init process at PID 1 and runs websocketd as its child. Use it. websocketd starts one process per connection and waits on each one it started. It does not reap arbitrary orphans. If a script spawns a background child and exits, that grandchild is re-parented to PID 1, and if PID 1 is websocketd it is never reaped. Zombie entries then accumulate for the life of the container. websocketd also installs no signal handler of its own. An init at PID 1 forwards SIGTERM from docker stop to it and gives the stop a predictable shape rather than a ten-second wait followed by SIGKILL. If you would rather not pass --init at every docker run, put an init binary in the image and make it the entrypoint: RUN apk add --no-cache tini ENTRYPOINT ["/sbin/tini", "--", "websocketd", "--port=8080", "--address=0.0.0.0", "/app/myscript.sh"] Your script's runtime must be in the image alpine has a shell and little else. A Python script needs apk add python3, a Node script needs nodejs, and so on. A missing runtime fails in one of two places. If the ENTRYPOINT command itself is missing, websocketd resolves it on PATH at startup and exits before binding, with unable to locate specified COMMAND on stderr. If the command is a script whose interpreter is missing, websocketd starts normally and each connection fails instead. Alpine uses musl rather than glibc. If your script's runtime or a compiled helper needs glibc, base the runtime stage on debian:bookworm-slim instead. Logs websocketd writes to stdout and stderr and never to a file, so docker logs is the log. The origin-policy startup banner goes to stderr and appears there on every start until you give websocketd an origin policy, which the ENTRYPOINT above does with --origin. Next Deploy to Kubernetes to run this image in a cluster. Serve behind nginx to put TLS and a public port in front of the container. The exposure checklist before publishing the port beyond your own machine. Why there is no pty . ======================================================================== Deploy to Kubernetes https://websocketd.com/docs/how-to/deploy/kubernetes/ ======================================================================== Three objects: a Deployment running your websocketd image, a Service in front of it, and an Ingress that keeps long-lived upgrades open. Build the image first, following run in a container . apiVersion: apps/v1 kind: Deployment metadata: name: websocketd spec: replicas: 2 selector: matchLabels: app: websocketd template: metadata: labels: app: websocketd spec: containers: - name: websocketd image: myregistry/myapp-ws:0.5.0 args: - "--port=8080" - "--address=0.0.0.0" - "--origin=https://ws.example.com" - "/app/myscript.sh" ports: - name: ws containerPort: 8080 readinessProbe: tcpSocket: port: ws initialDelaySeconds: 2 periodSeconds: 10 --- apiVersion: v1 kind: Service metadata: name: websocketd spec: selector: app: websocketd ports: - port: 80 targetPort: ws --- apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: websocketd annotations: nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" spec: ingressClassName: nginx rules: - host: ws.example.com http: paths: - path: / pathType: Prefix backend: service: name: websocketd port: number: 80 Apply it, and wss://ws.example.com/ reaches your script. The Ingress annotations The ingress-nginx controller proxies WebSocket upgrades without any opt-in annotation. What it does not do is guess how long you want the connection held open: its proxy_read_timeout and proxy_send_timeout default to 60 seconds, and they are idle timers, so an open but quiet WebSocket connection is cut at the minute mark. The two annotations above raise them. You can instead keep the connection from ever going idle, by adding --pingms=30000 to the container args. websocketd then sends a ping frame every 30 seconds, which resets the controller's read timer. Doing both is reasonable. The annotation keys above are specific to ingress-nginx. Traefik, HAProxy Ingress and the cloud controllers all proxy upgrades too, but each spells its timeouts differently. Check the controller you run. What a readiness probe can and cannot tell you Use a tcpSocket probe, as above. It answers exactly one question: is websocketd listening? That is the right question, because websocketd validates its whole configuration before it binds. A pod that accepts a TCP connection has a websocketd whose flags were accepted. An httpGet probe does not work. websocketd answers a plain, non-upgrade GET on an endpoint path with 404 Not Found, and Kubernetes counts any status outside 200 to 399 as a failure, so the pod never becomes ready. Neither probe tells you your script works. websocketd does not run your program until a client completes a WebSocket upgrade, so nothing has executed it at probe time. A broken interpreter, a missing data file or a script that exits immediately all pass readiness and fail on first connection. If you need that covered, add a --staticdir health file and probe it, or run a client-side check outside the cluster. Replicas and shared state Scaling out is safe as far as websocketd is concerned. It keeps nothing between connections, and each connection gets its own process wherever it lands. See the process model . It is your wrapped program that decides whether replicas are safe. If two connections must see each other's state, they now have to do so across pods. See sharing state across connections . Two connections from the same browser can land on different pods. Nothing in the configuration above pins them together, and websocketd offers no session affinity of its own. If you need it, set it on the Service or on the Ingress controller. Keeping the ports in step Three numbers have to agree: --port=8080 in the container args, the containerPort, and the Service's targetPort. Naming the port ws and referring to it by name, as above, removes two of the three chances to get that wrong. Next Run in a container for the image these objects run. The exposure checklist for what to settle before the Ingress is public. The security model for what --origin does and does not cover. ======================================================================== Serve over wss:// https://websocketd.com/docs/how-to/deploy/tls/ ======================================================================== Pass --ssl with a certificate and a key, and websocketd serves wss:// instead of ws://: websocketd --port=443 --ssl \ --sslcert=/etc/ssl/certs/example.com.crt \ --sslkey=/etc/ssl/private/example.com.key \ /opt/myapp/myscript.sh Clients connect to wss://example.com/. All three flags go together: give all of them or none. --sslcert and --sslkey without --ssl are rejected at startup. The certificate file must contain the server certificate followed by any intermediates, in PEM form. Browsers reject a chain they cannot complete. websocketd negotiates TLS 1.2 at the lowest. A client that offers nothing above TLS 1.1 fails the handshake. Requiring client certificates --sslca names a CA certificate file, and clients must then present a certificate that CA signed: websocketd --port=443 --ssl \ --sslcert=/etc/ssl/certs/example.com.crt \ --sslkey=/etc/ssl/private/example.com.key \ --sslca=/etc/ssl/certs/client-ca.crt \ /opt/myapp/myscript.sh Every connection is now verified against that CA, and an unverified client is refused during the handshake, before any HTTP request exists. --sslca requires --ssl. Drop --ssl from that command and websocketd refuses to start, exiting with code 1 and a message naming --sslca and --ssl on stderr, because mutual TLS has no handshake to verify a client certificate in without a TLS listener. The file --sslca points at holds the CA certificates in PEM form. If it contains no parseable certificate, websocketd fails to start rather than starting without verification. Terminating TLS at a proxy instead Most deployments do this. The proxy holds the certificate on 443, and websocketd runs plain on loopback behind it: websocketd --port=8080 --address=127.0.0.1 /opt/myapp/myscript.sh You get certificate renewal, HTTP/2 to the browser, and one place to manage TLS for every service on the host. See nginx , Apache and HAProxy for the proxy side. The trap: --sameorigin behind a TLS-terminating proxy If you terminate TLS at a proxy, do not use --sameorigin. It rejects every upgrade with 403 Forbidden. --sameorigin compares the browser's Origin header against the Host header of the request websocketd itself received, filling in a default port on each side from the scheme that side arrived under. Behind a TLS-terminating proxy the two sides disagree: The browser is on https://example.com, so the origin side resolves to host example.com, port 443. websocketd was reached over plain HTTP. Even with proxy_set_header Host $host; preserving the name, the Host it sees carries no port, so the request side resolves to host example.com, port 80. Ports differ, so the check fails. Preserving the original Host name is not enough on its own, because it is the port that mismatches. Use --origin instead, naming the public origin exactly: websocketd --port=8080 --address=127.0.0.1 --origin=https://example.com /opt/myapp/myscript.sh --origin is matched against the Origin header alone. It does not care what Host the proxy forwards, and it stays strict about scheme, host and port. https://example.com with no port matches only port 443. If you would rather keep --sameorigin, make the proxy forward a Host that carries the public port, for example nginx's proxy_set_header Host $host:$server_port; on a listen 443 ssl server. Both sides then resolve to 443 and the check passes. --origin is the simpler answer. --sameorigin is a good fit when the browser talks to websocketd directly: local development, or serving the client page from the same websocketd with --staticdir. Redirecting plain HTTP --redirport opens a second, plain-HTTP port that answers every request with a permanent redirect to the canonical address: websocketd --port=443 --ssl --sslcert=... --sslkey=... --redirport=80 /opt/myapp/myscript.sh A browser sent to http://example.com/ is redirected to https://example.com/. It is only useful when websocketd itself terminates TLS; a proxy in front already handles this. Next The exposure checklist before this goes on a public address. The security model for why the origin policy defaults to permissive and what it does not protect. Add authentication , which TLS does not give you. CLI flags for the exact form of each flag above. ======================================================================== Coexist with another web server https://websocketd.com/docs/how-to/deploy/share-a-port/ ======================================================================== Two servers cannot bind the same port. Put a reverse proxy on the public port and route by path: one prefix to websocketd, everything else to your application. websocketd has no mechanism for sharing a port directly, and the proxy is the answer every time. Give each server its own loopback port: websocketd --port=8080 --address=127.0.0.1 /opt/myapp/myscript.sh # your application, separately, on 127.0.0.1:3000 Then configure the proxy you already run. Each of these pages gives you a working WebSocket-capable configuration; add one path rule to it. nginx : a second location /ws/ { ... } block pointing at 8080, with the existing location / { ... } pointing at 3000. Apache : a ProxyPass "/ws/" "ws://127.0.0.1:8080/" alongside a ProxyPass "/" "http://127.0.0.1:3000/", longest prefix first. HAProxy : an acl ... path_beg /ws/ selecting the websocketd backend, with the application as default_backend. Watch the path the prefix leaves behind Decide whether the proxy strips /ws/ before forwarding, then check that against how you run websocketd. If you run a single command, the path does not matter. websocketd serves that one command on every path. If you run --dir, the path selects the script, so a stripped prefix and a preserved one reach different scripts. nginx strips the location prefix when proxy_pass carries a path (proxy_pass http://127.0.0.1:8080/;) and preserves it when it does not (proxy_pass http://127.0.0.1:8080;). Apache's ProxyPass replaces the matched prefix with the backend path. HAProxy forwards the path untouched unless you rewrite it. When the other server is the one that has to move If the application on the public port is not something you can put behind a proxy, the reverse works: leave it where it is and give websocketd its own port. Nothing requires a WebSocket endpoint to share the application's port, only the same host and, for --sameorigin, the same origin. See serving over wss:// for what the origin policy compares. Next The exposure checklist before the shared port is public. Add authentication , which the same proxy can do. ======================================================================== Check before exposing it publicly https://websocketd.com/docs/how-to/deploy/public-internet/ ======================================================================== Work through these before websocketd answers on a public address. A websocketd endpoint runs a program for whoever connects, and every item below follows from that. 1. Decide what binds the public address With no --address, websocketd listens on every interface. That is right only when websocketd itself is the public server. If a reverse proxy fronts it, which is the usual arrangement, bind loopback so nothing else can reach it: websocketd --port=8080 --address=127.0.0.1 /opt/myapp/myscript.sh See nginx , Apache or HAProxy . 2. Confirm the network in front forwards Binding a public interface is not the same as being reachable, and the two failures look identical from outside. Behind NAT on a home or office network, the router needs an explicit port-forwarding rule to the machine's private address. On a cloud provider, the network-level firewall is separate from the host's: a security group on AWS, a firewall rule on GCP, a network security group on Azure. An open host firewall alone is not enough. This is the usual cause of "it works when I curl it on the box, not from outside." 3. Set an origin policy The default accepts upgrades from any origin, and websocketd prints a warning about it on every start. Browsers do not apply the same-origin policy to WebSocket connections, so any page in any browser that can reach your server can drive the commands it serves. Pick one: --origin=https://example.com names the origins you accept. Use this behind a proxy. --sameorigin requires the Origin to match the Host websocketd itself received. Use this only when the browser reaches websocketd directly; it rejects every upgrade behind a TLS-terminating proxy. See serving over wss:// . --anyorigin keeps the permissive behaviour deliberately and silences the warning. Why the default is permissive, and what an origin check is worth, is in the security model . 4. Serve it over TLS Terminate TLS at the proxy, or in websocketd with --ssl. Either way the browser should be connecting to wss://, not ws://. See serving over wss:// . 5. Decide who is allowed to connect websocketd has no authentication of any kind. It does not read credentials, has no user model, and does not gate connections on anything but the origin policy and, with --sslca, a client certificate. Anyone who can complete a WebSocket upgrade can run the program you are serving. If that is not acceptable, put authentication in front of it. See authenticate in front of websocketd , and why there is no built-in authentication . 6. Cap concurrent processes --maxforks limits how many processes websocketd will have running at once, and defaults to 1024. Past the limit, upgrades and CGI requests are refused rather than queued; static files and redirects are unaffected. The default is a runaway backstop, not a capacity plan. Set it against what your machine can run at once, given what your script costs. 0 means unlimited, which on a public address means one client can fork until the machine stops. 7. Cap inbound message size --maxframesize rejects inbound WebSocket messages larger than its value and closes the connection. It defaults to 1048576 bytes, one mebibyte, which bounds how much a single client can make websocketd buffer. Raise it only if your protocol needs larger messages. 0 disables the limit. 8. Detect clients that vanish --pingms sets a ping interval in milliseconds and is off by default. With it set, websocketd pings each client at that interval and drops a connection whose pongs stop arriving for twice as long. Without it, a client that loses power rather than closing cleanly leaves its process running until something else notices. On a public address, set it. It also keeps proxy idle timeouts from cutting healthy connections. 9. Audit what --staticdir exposes If you serve files with --staticdir, point it at a directory containing only what is meant to be public. websocketd refuses any path with a segment beginning with ., so a .git directory or an .env file under --staticdir cannot be fetched by name. It also refuses to list a directory that has no index file, rather than generating one. Symlinks that point outside the directory are also blocked. So are files inside a --dir or --cgidir tree, so a script that also sits under --staticdir is not handed back as source. None of that vets what you put in the directory: a secret file with an ordinary name is served like anything else, so the directory itself still needs to hold only what you mean to publish. 10. Know where the logs go websocketd writes to stdout and stderr and never to a file. Under systemd that is the journal; in a container it is docker logs. Confirm something is capturing and retaining them before you need them. Next Run it under systemd so it survives a reboot. The security model for the reasoning behind items 3, 5 and 9. CLI flags for the exact form of every flag above. ======================================================================== Share state across connections https://websocketd.com/docs/how-to/patterns/share-state/ ======================================================================== To share state between connections, put the state outside the connection processes and make each connection's script a thin, disposable client of it. Every connection gets its own process with nothing between the processes, so there is nowhere inside websocketd for shared state to live. The process model covers why. Three places to put it, in increasing order of what they cost you. Pick one Put it in When Cost A shared file on disk One host, one writer, a feed every connection reads Nothing to install; no back pressure, no multi-host A message bus Topics with many subscribers, or you already run one Another service to operate A long-lived backend process The shared state has behaviour attached to it You write and run the backend The first two suit data that flows one way. Once connections need to affect each other, or the shared state needs rules, you want the third. A shared file on disk One writer appends lines to a file. Every connection tails it. #!/bin/sh # feed.sh exec tail -n 0 -f /var/log/sensor-feed.log websocketd --port=8080 ./feed.sh Whatever appends to /var/log/sensor-feed.log now reaches every connected client. With two clients connected and two lines appended, each client receives both: reading 21.5 reading 21.7 -n 0 starts each connection at the end of the file, so a client that connects late gets what happens next rather than the whole history. Drop it if you want the backlog. Do not reach for a named pipe here. A FIFO does not fan out. Every line goes to exactly one reader, chosen by the kernel, so two connections reading one FIFO steal lines from each other. Two cat processes on one FIFO, six lines written: reader A got: line 3, line 6 reader B got: line 1, line 2, line 4, line 5 A FIFO is the right tool for handing a stream to one consumer, and the wrong tool for handing it to all of them. Appending is the other thing to be careful about. Two writers appending to the same file at the same moment can interleave a long line. Keep the lines short, or serialise the writes through a single writer process. A message bus Each connection's script subscribes to a topic and publishes to it, using the bus's own command-line client. Redis, NATS and MQTT all work the same way here. #!/bin/bash # chat.sh CHANNEL=chatroom # Subscribe in the background and forward payloads to the browser. # redis-cli prints each delivery as three lines: the word "message", # the channel name, then the payload. Only the payload is wanted. redis-cli subscribe "$CHANNEL" | while read -r kind; do read -r channel read -r payload [ "$kind" = "message" ] && printf '%s\n' "$payload" done & SUBSCRIBER=$! trap 'kill $SUBSCRIBER' EXIT # Forward everything the browser sends into the channel. while read -r message; do redis-cli publish "$CHANNEL" "$message" >/dev/null done websocketd --port=8080 ./chat.sh The bus owns the one-message-many-subscribers relationship. Your script never does, and neither does websocketd. This costs you a service to run and a second process per connection, and it buys you fan-out that already works across more than one host. If you run a bus already, this is usually the cheapest of the three. A long-lived backend process Run one process yourself, outside websocketd, holding the shared state in ordinary memory. Each connection's script does nothing but relay bytes between the WebSocket and that process. Start the backend however you normally run a service, listening on a Unix domain socket: ./hub --socket=/var/run/hub.sock The bridge script needs no custom code, because socat already relays stdin and stdout to a socket: #!/bin/sh # bridge.sh exec socat - UNIX-CONNECT:/var/run/hub.sock websocketd --port=8080 ./bridge.sh Every connection gets its own throwaway bridge.sh, and all of them are clients of the same backend. With a hub that echoes each line to every connected client, a message sent by one browser arrives at another that sent nothing: client A received: hello from B client B received: hello from B This is the most flexible of the three and the only one where the shared state can have logic attached to it: a game board, a session registry, a rate limiter, an authority on who is allowed to say what. The cost is that you write the backend, and that it is now a service you have to keep running. Give it a supervisor, as in running under systemd . If the backend must be started once and only once, run a program once, or keep it running covers how to enforce that. Next The process model explains why there is no shared state to begin with, and what the model buys in exchange. Design decisions covers why broadcast was never built in. Pass data into your script covers getting a room or topic name into each connection. ======================================================================== Add authentication https://websocketd.com/docs/how-to/patterns/add-auth/ ======================================================================== websocketd has no built-in authentication, so the check goes either in a reverse proxy in front of it or inside the script it wraps. This page gives you both. The security model covers why the choice is yours rather than a flag. Your script cannot look up who is calling. AUTH_TYPE, REMOTE_USER and REMOTE_IDENT are part of the CGI specification, and websocketd sets all three to the empty string on every connection. Reading them tells you nothing, ever. Which one to use Put the check in front if a reverse proxy is already in your path, or if the endpoint faces the public internet. An unauthenticated request never reaches websocketd, so no process is started, no --maxforks slot is taken, and one place in your stack owns the decision. Put the check in the script if there is no proxy and you want to keep the deployment to one moving part. The cost is that websocketd forks your script before the check runs, and every script you write has to get the check right on its own. In front: a reverse proxy Bind websocketd to the loopback interface so the proxy is the only way in, and use --origin rather than --sameorigin, because a TLS-terminating proxy rewrites the Host that --sameorigin compares against: websocketd --port=8080 --address=127.0.0.1 \ --origin=https://example.com ./myscript.sh A shared password Enough for an internal tool. Create the password file once: htpasswd -c /etc/nginx/websocketd.htpasswd someuser Then gate the proxied location on it: location / { auth_basic "Restricted"; auth_basic_user_file /etc/nginx/websocketd.htpasswd; proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; } nginx answers an unauthenticated request with 401 before the upgrade is attempted. Your script never runs. It also never learns which user authenticated, only that somebody did. A token, checked by your own service To validate a session cookie, a JWT, or an API key, hand the decision to a service of your own with nginx's auth_request: location = /_auth_check { internal; proxy_pass http://127.0.0.1:9000/verify; proxy_pass_request_body off; proxy_set_header Content-Length ""; proxy_set_header Cookie $http_cookie; } location / { auth_request /_auth_check; auth_request_set $auth_user $upstream_http_x_auth_user; proxy_set_header X-Auth-User $auth_user; proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; } Your service at 127.0.0.1:9000/verify answers 200 to allow and 401 or 403 to deny. When it allows, it can return an X-Auth-User response header naming the caller, which nginx forwards and websocketd turns into an environment variable: HTTP_X_AUTH_USER=[ada] Every request header becomes HTTP_ plus the header name uppercased with dashes turned into underscores. That is how your script gets a per-user identity without websocketd knowing anything about authentication. See the CGI environment for the full mapping. That identity is only as trustworthy as the path in front of it. It is a header, so anything that can reach port 8080 directly can set it to whatever it likes. Binding to 127.0.0.1 is what makes it mean anything. The base proxy configuration, without the auth layer, is in serving behind nginx . In the script: check a token The token has to reach your script through the request, which means the CGI environment . Two variables carry it, and which one you use is decided by the client: QUERY_STRING holds the raw query string, exactly as sent, with no flag needed. Use it for browser clients: the browser WebSocket API cannot set request headers, so the URL is the only channel it has. HTTP_AUTHORIZATION, or any other HTTP_ variable, holds the corresponding request header. Use it for scripts and services, which can set headers freely. Both are written by the client. Treat them as hostile input, compare them in constant time, and never pass either to a shell. Keep the expected secret out of the URL and out of the command line. Put it in websocketd's own environment and name it in --passenv: export APP_TOKEN=s3cret websocketd --port=8080 --passenv=PATH,APP_TOKEN ./authed.py --passenv replaces the default list rather than adding to it, which is why PATH is named too. Pass data into your script has the details. #!/usr/bin/env python3 # authed.py import hmac, os, sys from urllib.parse import parse_qs expected = os.environ.get("APP_TOKEN", "") token = parse_qs(os.environ.get("QUERY_STRING", "")).get("token", [""])[0] if not expected or not hmac.compare_digest(token, expected): print("unauthorized") sys.exit(1) print("authorized") for line in sys.stdin: print("echo: " + line.rstrip("\n")) sys.stdout.flush() Connecting to ws://localhost:8080/?token=s3cret and sending hi: authorized echo: hi Connecting with the wrong token, or none: unauthorized The script exits, websocketd closes the connection, and nothing else runs. A token in a URL leaks websocketd logs the full request URL, query string included, at access level. A connection carrying ?token=s3cret writes that secret into the log: ACCESS | session | url:'http://127.0.0.1:8080/?token=s3cret' ... | CONNECT The same string also lands in browser history, in Referer headers, and in the logs of every proxy in the path. Prefer a header where the client can set one. Where it has to be the query string, use a short-lived token your application issues per session, not a long-lived secret. What this does not cover An origin policy is not authentication. --sameorigin and --origin constrain which web pages a browser will let connect; they place no constraint at all on a script with a WebSocket library, which sets whatever origin it likes. Mutual TLS is the one form of client authentication websocketd performs itself. --sslca requires every client to present a certificate signed by a named authority, verified before your program is launched. It suits machine-to-machine deployments and not browsers. See serving over wss:// . Next The security model covers the origin policy, TLS, and why authentication is not built in. The exposure checklist is what to work through before this matters. Environment variables is the complete table your script can read from. ======================================================================== Patterns https://websocketd.com/docs/how-to/patterns/ ======================================================================== Each page here solves one shape of problem: state that has to cross connections, a caller that has to be authenticated, a program that must run once or keep running, data that has to reach your script, and an endpoint you want to drive before any client exists. Reach for one when you know what you want to build and need to know how the one-process-per-connection model lets you build it. ======================================================================== Run a program once, or keep it running https://websocketd.com/docs/how-to/patterns/run-once/ ======================================================================== websocketd starts one fresh process per connection and starts nothing at all until the first client arrives, so neither "run this once" nor "keep this running" happens on its own. Both are things you arrange around it. Two opposite problems live on this page. Go to the one you have: Only one instance may exist at a time, because it holds a device, a lock, or a resource that does not tolerate a second copy. Use a wrapper that refuses to start twice . The program must survive for the whole connection, or longer, without being cut off mid-write. Give it signal handling . Enforce a single instance Wrap the real program in a script that takes a lock before handing over. Every connection still gets its own wrapper process. Only one wrapper ever reaches the program. #!/bin/sh # run-once.sh LOCKDIR=/tmp/control-motor.lock if ! mkdir "$LOCKDIR" 2>/dev/null; then echo "busy: another connection holds the motor" exit 0 fi trap 'rmdir "$LOCKDIR"' EXIT INT TERM exec ./control-motor.sh websocketd --port=8080 ./run-once.sh Creating a directory is atomic on every POSIX filesystem, which is what makes this a lock rather than a race. A second connection arriving while the first holds it gets a message and nothing else: busy: another connection holds the motor When the first connection closes, the wrapper's trap removes the lock directory, and the next connection acquires it normally. On Linux you can use flock instead, which releases the lock when the file descriptor closes and so survives a wrapper that dies without running its trap: #!/bin/bash exec 9>/tmp/control-motor.lock flock -n 9 || { echo "busy"; exit 0; } exec ./control-motor.sh flock ships with util-linux and is not present on macOS or the BSDs, which is why the portable version above uses mkdir. Let the newest connection win instead If the right answer is "the latest connection takes over" rather than "the second connection is refused", kill the incumbent instead of refusing the newcomer: #!/bin/sh # takeover.sh PIDFILE=/tmp/control-motor.pid if [ -f "$PIDFILE" ]; then kill -TERM "$(cat "$PIDFILE")" 2>/dev/null sleep 1 fi echo $$ > "$PIDFILE" exec ./control-motor.sh Pick whichever matches what should happen when two people reach for the same resource. websocketd has no opinion, because a chat room and a motor controller need opposite answers. Keep a program running A program that loops rather than exiting when its input runs out has to handle the signals websocketd sends when the connection closes. This is the whole contract: handle SIGINT and SIGTERM, and exit promptly on either. #!/usr/bin/env python3 import signal, sys, time def shutdown(signum, frame): flush_everything() sys.exit(0) signal.signal(signal.SIGINT, shutdown) signal.signal(signal.SIGTERM, shutdown) while True: do_work() sys.stdout.flush() time.sleep(1) SIGINT arrives first, so a program that handles only SIGTERM gets one fewer chance to clean up than it thinks. Handle both. A program that ignores both, and does not read stdin either, does not survive: websocketd escalates to SIGKILL, and the process is gone under a second after the connection closes, having run no cleanup at all. It rides websocketd's teardown ladder to the bottom on every disconnect. Process lifecycle has the ladder and the --closems flag that lengthens it, which is what you want if your cleanup needs a network round trip. Reading stdin is the other way to notice. websocketd closes your program's stdin first, so a loop of the form while read -r line ends by itself when the connection does, with no signal handling at all. Keep it running past the connection Anything still in the process group when the connection ends is killed. If a program has to outlive the connection that started it, do not start it from websocketd at all. Run it as a service in its own right, and let each connection bridge to it: #!/bin/sh exec socat - UNIX-CONNECT:/var/run/hub.sock That is the third pattern in share state across connections , and it is the answer to "one process for the whole server" as well as to "shared state". Use systemd , or whatever supervises services on your host, to start and restart it. Next Process lifecycle covers exactly when a process starts, the teardown ladder, and process groups. The process model covers why there is one process per connection and no pool. Share state across connections covers the long-lived backend this page points at. ======================================================================== Pass data into your script https://websocketd.com/docs/how-to/patterns/pass-arguments/ ======================================================================== Two different mechanisms put data into a wrapped script, and telling them apart is most of the job: The query string carries data from the client and is different for every connection. Your script reads it from QUERY_STRING. No flag turns it on. --passenv copies named variables out of websocketd's own process environment into every connection's script. Same value every time. It has nothing to do with the request. If the value should vary per connection, it belongs in the URL. If it should be identical for every connection, it belongs in websocketd's environment. Per-connection data: the query string Every connection's script gets the query string it was opened with, in QUERY_STRING. #!/bin/bash # show-query.sh echo "QUERY_STRING is: $QUERY_STRING" websocketd --port=8080 ./show-query.sh Connect to ws://localhost:8080/?name=Ada&room=general and the script prints: QUERY_STRING is: name=Ada&room=general QUERY_STRING is the raw string. Splitting it on & and =, and percent-decoding the parts, is your script's job, exactly as it is for any CGI script: #!/usr/bin/env python3 import os from urllib.parse import parse_qs params = parse_qs(os.environ.get("QUERY_STRING", "")) room = params.get("room", ["general"])[0] The client writes that string, so anything in it is attacker-controlled. Validate it, and keep it away from a shell. The query string is one of a couple of dozen request variables websocketd builds for each connection, alongside REMOTE_ADDR, REQUEST_URI, and an HTTP_ variable per request header. Environment variables is the full table. Fixed configuration: --passenv --passenv takes a comma-separated list of variable names. websocketd looks each one up in its own environment at startup and copies the value into every child process. export APP_TOKEN=s3cret websocketd --port=8080 --passenv=PATH,APP_TOKEN ./show-config.sh #!/bin/bash # show-config.sh echo "APP_TOKEN is: ${APP_TOKEN:-<unset>}" APP_TOKEN is: s3cret Every connection sees the same value, because it came from the shell that started websocketd rather than from the client. A variable that is unset or empty in websocketd's environment is dropped rather than forwarded as an empty string. --passenv replaces the default, it does not extend it The default is PATH plus your platform's library search path, and naming your own variable throws that default away: websocketd --port=8080 --passenv=APP_TOKEN ./show-config.sh The child's environment now has no PATH entry at all. Name PATH yourself whenever you use the flag: --passenv=PATH,APP_TOKEN The missing PATH can be slow to notice, because some shells invent one. Run the command above with a bash script and it reports a PATH that websocketd never set: PATH is: /usr/gnu/bin:/usr/local/bin:/bin:/usr/bin:. That is bash's own compiled-in fallback for a missing PATH, not the one you were running with, and it will not find anything you installed. A program that reads the environment directly sees the truth: no PATH key. Everything websocketd does not carry across is gone. On every platform except Windows, websocketd clears its own environment after reading --passenv, so nothing reaches your script by accident. The CGI environment covers why it works that way. Side by side Query string --passenv Variable your script reads QUERY_STRING The name you listed Where the value comes from The client's URL websocketd's own environment Varies per connection Yes No Trust Client-controlled Operator-controlled Flag needed None --passenv Typical use Room name, user id, a per-connection setting API key, PATH, deployment config The two do not meet. --passenv=QUERY_STRING does nothing useful: websocketd's own environment has no QUERY_STRING to copy, and the real one is already in your script's environment with no flag. The URL path can select the script With --dir, the path picks which script runs and the query string still arrives as usual. websocketd --port=8080 --dir=./scripts Connecting to ws://localhost:8080/greet.sh?name=Ada runs ./scripts/greet.sh with QUERY_STRING set to name=Ada, and tells the script where it sits in the URL: SCRIPT_NAME=[/greet.sh] PATH_INFO=[] Anything after the script's own path is handed over in PATH_INFO, which is how a script routes on the URL. Connecting to ws://localhost:8080/greet.sh/extra/path?x=1: SCRIPT_NAME=[/greet.sh] PATH_INFO=[/extra/path] --cgidir builds these two variables by different rules. If you route on them, check the environment variable reference for the mode you are using. Fixed command-line arguments Anything after the command on the websocketd command line is passed straight through to your program, identically for every connection: websocketd --port=8080 ./myscript.sh --verbose /var/data This is a third fixed channel, alongside --passenv, and like it, it cannot vary by connection. Next The CGI environment explains the request-to-environment contract and where the trust boundaries are. Environment variables is the complete table, including the --cgidir differences. Add authentication uses both mechanisms together: a per-connection token in the URL, checked against a secret from --passenv. ======================================================================== Debug a script before writing a client https://websocketd.com/docs/how-to/patterns/debug-a-script/ ======================================================================== Start websocketd with --devconsole and it serves a test page from the same port as your endpoint. You can connect, send frames, and read exactly what your script writes, without writing a line of client code. websocketd --port=8080 --devconsole ./myscript.sh Open http://localhost:8080/ in a browser. The console works out its own WebSocket target from the page URL, so the address bar is already filled in with ws://localhost:8080/. Press Connect, or Enter in the address bar. Send a frame and read the reply Type into the box along the bottom and press Enter, or Send. Each frame appears as a row in the middle list, with the time it happened, an arrow giving its direction, its size in bytes, and the text itself. Sent and received frames sit in the same list in the order they occurred, which is what makes a missing reply or an out-of-order one obvious. Every line your script writes to stdout becomes one received frame, because a newline is the frame boundary. If your script prints a burst of output and the console shows nothing for several seconds and then all of it at once, the problem is not websocketd: your language is buffering. The fix is on your language's page, from Python to C , and the reason is in output buffering . Click a row and the right-hand pane shows that one frame in full: its size, when it arrived, how long after the frame you sent, and the payload under Pretty, Raw or Hex. Pretty formats JSON, so a malformed response is visible as soon as it fails to format. Hex is the one to reach for when a frame looks right and is not: a stray carriage return, a byte-order mark, or trailing whitespace shows up there and nowhere else. The footer counts frames sent and received, total traffic, and how long the connection has been open. Watch the open time to catch a connection that is being dropped and silently remade. See your script's stderr By default your script's stderr does not reach the browser. It is written to websocketd's log, on websocketd's own stdout, tagged at error level: ERROR | stderr | url:'http://127.0.0.1:8080/' ... | to stderr, from the script To see it in the console alongside the output, add --passstderr: websocketd --port=8080 --devconsole --passstderr ./myscript.sh Both streams then arrive as tagged JSON, one object per frame: {"stream":"stdout","data":"HTTP_X_AUTH_USER=[ada]"} {"stream":"stderr","data":"to stderr, from the script"} --passstderr wraps stdout too, so the frames your real client receives are no longer the bare lines your script printed. It is a debugging flag, not a production one. Server-side logging of stderr happens either way, so turning it off loses you nothing but the browser view. One HTTP surface, one owner --devconsole cannot be combined with --staticdir or --cgidir. All three want to answer plain HTTP requests, and websocketd refuses to start rather than pick one: FATAL | server | Invalid parameters: --devconsole cannot be used with --staticdir. Pick one. It exits with code 4. Use --devconsole while you are writing the script, and switch to --staticdir once you have a client page of your own. The exit code reference lists the rest. While --devconsole is on, the console page is what every HTTP path returns, so http://localhost:8080/anything serves the console too. That is deliberate: it means the console is reachable at the same URL as the endpoint you are testing, including under --dir. Next Dev console is the exact contract: what the page is, what it serves, and the policy it runs under. Message framing explains why one line is one frame, and what --binary changes. Pass data into your script covers testing an endpoint that expects a query string. ======================================================================== How-to guides https://websocketd.com/docs/how-to/ ======================================================================== You have a specific job to do and you want the steps. Every page in this section leads with a working solution. Caveats, variations, and the reasoning come after it, or as a link. These guides assume you already have a websocketd binary and have wrapped at least one script with it. If neither is true yet, work through the tutorial first. Pick the group that matches what is in front of you. Reach for language fixes when your script misbehaves, deployment guides when the surrounding infrastructure does, and patterns when the problem belongs to neither and is really about shape. When you want a precise fact instead of a procedure, the flag reference has it. When you want the reasoning behind a fix, output buffering and the process model cover most of it. ======================================================================== CLI flags https://websocketd.com/docs/reference/cli-flags/ ======================================================================== Every flag websocketd accepts, in alphabetical order, with its default and its exact effect. Order on the command line matters: see flag placement . For a bare list with no notes, run websocketd --help. Flags --address Default: none (may be given multiple times) Interfaces to bind to (e.g. 127.0.0.1 or [::1]). May be given more than once to bind several interfaces. Every address listens on the same --port. --anyorigin Default: false Explicitly accept any origin (the current default) and silence the origin-policy startup warning. States the current permissive-origin default explicitly and silences the origin-policy warning websocketd prints to stderr at startup. Combining it with --sameorigin or --origin is rejected at startup, since those restrict what this flag accepts. A future websocketd release defaults to --sameorigin instead. See security model . --binary Default: false Set websocketd to experimental binary mode (default is line by line). Switches the WebSocket message type to binary frames instead of text, and removes the newline framing rule: output is forwarded in raw chunks as read, with no newline required, and no newline is appended to input. It does not allocate a pseudo-terminal, so a program that only behaves interactively under a terminal still does not do so here (issue #443). See message framing . --cgidir=CGIDIR Default: "" (empty) Serve CGI scripts from this directory over HTTP. Scripts here run as CGI programs answering ordinary HTTP requests, not WebSocket upgrades. The request path must name the script file exactly; there is no extra path information after it. When the CGI directory sits inside the --staticdir tree, its scripts also answer at their path there: with --staticdir=/PAGE --cgidir=/PAGE/cgi-bin, /cgi-bin/hello.sh runs the same script as /hello.sh. That position is decided by which directory each flag names rather than by how it is spelled, so a symlinked or differently-cased pair of paths routes the same way, and a --cgidir genuinely outside the static tree is not brought inside it by a symlink there. It is worked out once and reused, so a deployment symlink flipped under a running server does not re-route requests; restart to pick up a new layout. Files in the CGI directory are never served as static content. The variables a CGI script receives differ from the WebSocket ones, because Go's net/http/cgi builds them: see environment variables . May be combined with --dir, which serves a separate directory over WebSocket. --closems=CLOSEMS Default: 0 Extra time added to each of the first three waits before websocketd escalates to the next termination signal. The value is added to each of the first three steps of the teardown ladder: closing stdin (100 ms), SIGINT (250 ms), and SIGTERM (500 ms). The final SIGKILL step waits a fixed 1000 ms and is not affected. Signals are sent whatever the value; 0 means no extra delay, not no signals. See process lifecycle . --devconsole Default: false Enable development console (cannot be used in conjunction with --staticdir or --cgidir). All three of --devconsole, --staticdir and --cgidir claim the same non-WebSocket HTTP surface, so websocketd exits with code 4 if the console is combined with either of the others. See dev console . --dir=DIR Default: "" (empty) Base directory for WebSocket scripts. Any file under this directory is reachable as its own WebSocket endpoint, named after its path within the directory. The matched path becomes SCRIPT_NAME and anything left over becomes PATH_INFO. The executable bit is not checked when the path is resolved; whether the file runs is decided when websocketd launches it. A file reached through a symlink that leaves the directory is answered with 404. Files here are never served as static content, so a plain GET of a script that also sits inside a --staticdir tree returns 404 rather than the script's source. A script keeps the URL --dir gives it and gains no second one from where the directory sits: unlike --cgidir, no URL prefix is derived for it inside --staticdir. Giving both --dir and a COMMAND is rejected at startup. May be combined with --cgidir, which serves a separate directory as CGI over HTTP. --header Default: none (may be given multiple times) Custom headers for any response. May be given more than once. Added to successful WebSocket upgrade responses and to every response the WebSocket handler did not produce: the dev console, CGI, static files, and 404s. Error responses from the WebSocket handler itself, such as a rejected upgrade or a 429, carry no configured headers. --header-http Default: none (may be given multiple times) Custom headers for all but WebSocket upgrade HTTP responses. May be given more than once. Added to every response the WebSocket handler did not produce: the dev console, CGI, static files, and 404s. --header-ws Default: none (may be given multiple times) Custom headers for successful WebSocket upgrade responses. May be given more than once. Added to successful WebSocket upgrade responses only. --help Default: false Print help and exit. --license Default: false Print license and exit. --loglevel=LOGLEVEL Default: access Log level, one of: debug, trace, access, info, error, fatal, none. --maxforks=MAXFORKS Default: 1024 Max forks, zero means unlimited. Each WebSocket connection and each CGI request is a full subprocess, and this caps how many may be live at once. Beyond the cap, an upgrade or CGI request is answered with 429 Too Many Requests rather than queued. Zero means unlimited. Operators running many concurrent long-lived connections have hit the default ceiling (issues #226, #228, #356). See the process model . --maxframesize=MAXFRAMESIZE Default: 1048576 Max inbound WebSocket message size in bytes (0 = unlimited). An inbound WebSocket message larger than this limit is rejected and the connection is closed with status 1009 (message too big), which bounds how much one client can make websocketd buffer. Zero disables the limit. A negative value is rejected at startup with exit code 1, because it would otherwise read as unlimited and silently remove the limit (issue #472). See security model . --origin=ORIGIN Default: "" (empty) Restrict upgrades if origin does not match the list. Entries are comma-separated. An entry with no scheme (just host[:port]) matches both http and https origins; an entry prefixed "https://" matches https only. An entry with an explicit port matches that port only. An entry with no port matches only the default port of a scheme it accepts, 80 for http and 443 for https. Appending ":*" to an entry matches any port on that host (issue #473). Combining this flag with --anyorigin is rejected at startup. See security model . --passenv=PASSENV Default: platform-dependent List of envvars to pass to subprocesses (others will be cleaned out). The default is PATH plus the platform's shared library search path: PATH,LD_LIBRARY_PATH on Linux, PATH,DYLD_LIBRARY_PATH on macOS, and PATH,SystemRoot,COMSPEC,PATHEXT,WINDIR on Windows. Passing --passenv REPLACES that default rather than adding to it, so --passenv=API_KEY alone leaves the child with no PATH. A named variable that is empty or unset in websocketd's own environment is dropped, not forwarded as an empty string. HTTPS is always skipped, because that variable is websocketd's own --ssl signal. This flag controls only which of websocketd's OWN environment variables reach the child; per-request CGI variables such as QUERY_STRING are built separately and are unaffected by it, a recurring point of confusion (issues #202, #223, #312, #391). See passing data into your script . --passstderr Default: false Forward STDERR to WebSocket clients as tagged JSON messages, alongside tagged STDOUT (mutually exclusive with --binary). The child's stdout and stderr both reach the client as JSON objects of the form {"stream":"stdout","data":"..."}, one per line of output, so the two streams can be told apart. Plain (untagged) output is no longer sent. stderr is still written to websocketd's own log as well. Combining it with --binary is rejected at startup with exit code 1. --pingms=PINGMS Default: 0 WebSocket ping interval in milliseconds (0 disables). Zero disables pings entirely, and an idle connection is then never timed out; this is the default, and it accounts for a long run of "mystery disconnect" reports where the connection was in fact dropped by something in between (issues #37, #209, #260, #275, #439). A non-zero value sends a WebSocket ping every interval and sets a read deadline of twice the interval. Only an incoming pong resets that deadline, so a client that keeps sending data but never answers a ping is disconnected within 2x --pingms just the same. See process lifecycle . --port=PORT Default: 0 HTTP port to listen on. The default of 0 is not a real port: it means 80, or 443 when --ssl is given. Every --address binds this same port. --redirport=REDIRPORT Default: 0 HTTP port to redirect to canonical --port address. Runs a second, plain HTTP listener on this port whose only response is a 301 redirect to the main listener. The redirect target keeps the host the client itself sent, along with the path and query it asked for, and rewrites only the scheme and the port, so a link into the site arrives at that link and it is not an open redirect. --reverselookup Default: false Perform reverse DNS lookups on remote clients. Sets REMOTE_HOST to the result of a reverse DNS lookup of the client's address instead of the address itself. The lookup happens on every connection. It has no effect on --cgidir requests, where net/http/cgi sets REMOTE_HOST to the client IP regardless. See environment variables . --sameorigin Default: false Restrict upgrades if origin and host headers differ. Accepts an upgrade only when the host and port in the request's Origin header match the host and port in its Host header. A request with no Origin header is treated as origin "file:" and does not match. Combining it with --anyorigin is rejected at startup. See security model . --socketmode=SOCKETMODE Default: "" (empty) Octal permission bits to force on the --unixsocket file (e.g. 0700); default follows umask. Applied with chmod to the socket file immediately after bind, so the window in which the umask-derived mode applies is as short as it can be. An empty value leaves the mode to the process umask; websocketd does not change the default socket permissions (issue #474). A value of 0, a value above 0777, and a value that is not octal are each rejected at startup with exit code 1. It applies only to --unixsocket. --ssl Default: false Use TLS on listening socket (see also --sslcert and --sslkey). Requires both --sslcert and --sslkey; giving --ssl without them, or either of them without --ssl, is rejected at startup with exit code 1. The listener negotiates TLS 1.2 or higher. The spawned process additionally receives HTTPS=on. See serve over wss:// . --sslca=SSLCA Default: "" (empty) CA certificate file for client certificate verification (mutual TLS). Turns on mutual TLS: client certificates are required and verified against this CA file. It requires --ssl; giving --sslca without --ssl is rejected at startup with exit code 1, because mutual TLS has no handshake to verify a client certificate in without a TLS listener (issue #477). See serve over wss:// . --sslcert=SSLCERT Default: "" (empty) Should point to certificate PEM file when --ssl is used. Read only when --ssl is given. Giving it without --ssl is rejected at startup with exit code 1. --sslkey=SSLKEY Default: "" (empty) Should point to certificate private key file when --ssl is used. Read only when --ssl is given. Giving it without --ssl is rejected at startup with exit code 1. --staticdir=STATICDIR Default: "" (empty) Serve static content from this directory over HTTP. Four kinds of request are answered with 404 rather than served: a path with a segment beginning with "." (.git, .env, .ssh, ...); a directory with no index.html file, since directories are never listed; a file reached through a symlink that leaves the directory; and any file inside a --dir or --cgidir tree, whichever URL reaches it. That last refusal is dropped when --staticdir names one of those directories itself, or a directory inside one. A first path segment of exactly .well-known is exempt from the dotfile rule, so ACME challenges and security.txt are still served; a dotfile nested deeper inside it is not. None of that vets the directory's contents: a secret under an ordinary name is still served, so point --staticdir at a tree holding only what is meant to be public. Rejected together with --devconsole. Why each of these is refused is in the security model . --unixsocket=UNIXSOCKET Default: "" (empty) Path of a Unix domain socket to listen on, in addition to (or instead of) --address/--port. Served in addition to the TCP listener. When --unixsocket is the only listening flag given, with no --port, --address, or --redirport, no TCP listener is started at all. At startup a socket file already at the path is probed: if nothing is listening on it, it is removed as stale and rebound; if something is, websocketd exits with code 3 rather than making the running server unreachable. The file is not removed on exit. --version Default: false Print version and exit. Generated from websocketd's flag definitions by tools/gendocs. Edit the generator, not this page. ======================================================================== Environment variables https://websocketd.com/docs/reference/environment-variables/ ======================================================================== A process websocketd spawns for a WebSocket connection receives the variables below, in CGI style. A process spawned for a --cgidir request receives a different set, built by Go's net/http/cgi; the differences are listed at the end of this page. Client-controlled means the value comes from data the client sent and can therefore be anything the client chooses. RFC 3875 variables Variable Populated from Client-controlled SERVER_SOFTWARE websocketd/ followed by the version string No REMOTE_ADDR The client's source IP address. Literally unix-socket for a client connected over --unixsocket No REMOTE_HOST The reverse DNS name of the client's address when --reverselookup is given, otherwise the same value as REMOTE_ADDR No SERVER_NAME The host part of the request's Host header Yes SERVER_PORT The port part of the request's Host header, or 80, or 443 under --ssl, when the header carries no port Yes SERVER_PROTOCOL The request's HTTP version, for example HTTP/1.1 No GATEWAY_INTERFACE The constant CGI/1.1 No REQUEST_METHOD The request method, GET for a WebSocket upgrade No SCRIPT_NAME Under --dir, the leading path segments that resolved to a file. With a single COMMAND, always / Partly: derived from the request path, but only ever a path that resolves to a file under --dir PATH_INFO Under --dir, whatever path is left after SCRIPT_NAME. With a single COMMAND, the whole request path Yes PATH_TRANSLATED The whole request path, before the query string Yes QUERY_STRING Everything after ? in the request URL, raw and still percent-encoded Yes SERVER_NAME and SERVER_PORT come from the Host header, not from the address websocketd is bound to, which matches net/http/cgi and virtual-hosted web servers. This is settled behaviour, not a defect (issue #475). Variables set to an empty string Each of these is set, and set to an empty string. websocketd sets them explicitly so that a value cannot leak in from its own environment. Variable Why it is empty AUTH_TYPE websocketd performs no authentication REMOTE_USER websocketd performs no authentication REMOTE_IDENT websocketd performs no authentication CONTENT_LENGTH A WebSocket upgrade carries no request body CONTENT_TYPE A WebSocket upgrade carries no request body Non-standard variables Not part of RFC 3875, and commonly provided by CGI-style servers. Variable Populated from Client-controlled UNIQUE_ID A random per-connection identifier, modelled on Apache's mod_unique_id No REMOTE_PORT The client's source TCP port. Empty for a --unixsocket client, which has none No REQUEST_URI The full request target, path and query string, for example /foo/blah?a=b Yes HTTPS The constant on. Set only under --ssl; otherwise the variable is absent, not empty No Request headers Every request header becomes one variable named HTTP_ followed by the header name uppercased with each - replaced by _. X-Forwarded-For becomes HTTP_X_FORWARDED_FOR. All of them are client-controlled. Repeated headers of the same name are joined with , into one value. Carriage returns and newlines within a value are replaced with spaces, and the result is trimmed. There is no HTTP_HOST. Go's HTTP server moves the Host header out of the header map before websocketd sees it; the host reaches the process as SERVER_NAME and SERVER_PORT instead. One header is dropped: a request header named Proxy never becomes HTTP_PROXY. Many HTTP client libraries route outbound requests through whatever HTTP_PROXY names, so forwarding it would let a remote caller redirect a spawned program's own traffic. This is the httpoxy vulnerability, CVE-2016-5385. The comparison uses the header's canonical form, so proxy, Proxy, and PROXY are all dropped. Go's net/http/cgi drops it for the same reason. Variables forwarded from websocketd's own environment --passenv names, comma-separated, which of websocketd's own environment variables are copied into every spawned process. Nothing else from websocketd's environment reaches the process: on every platform except Windows, websocketd clears its own environment after reading this list. Platform Default --passenv Linux PATH,LD_LIBRARY_PATH macOS PATH,DYLD_LIBRARY_PATH Windows PATH,SystemRoot,COMSPEC,PATHEXT,WINDIR Four facts govern the list: Passing --passenv replaces the default rather than adding to it. --passenv=API_KEY alone leaves the spawned process with no PATH. A named variable that is unset, or set to an empty string, in websocketd's environment is dropped rather than forwarded empty. HTTPS is skipped even when named, because that variable is websocketd's own --ssl signal. The list has no effect on the request-derived variables above. They are built per request and are always present. Differences under --cgidir A --cgidir request is handed to Go's net/http/cgi, which builds its own environment. SERVER_SOFTWARE is overridden to websocketd's value and the --passenv list is added, but the rest comes from Go. GATEWAY_INTERFACE, SERVER_PROTOCOL, REQUEST_METHOD, QUERY_STRING, REQUEST_URI, SERVER_NAME, SERVER_PORT, REMOTE_ADDR, REMOTE_PORT, and the HTTP_<NAME> mapping carry the same meaning in both modes, and Proxy is dropped in both. These differ: Variable WebSocket (COMMAND or --dir) --cgidir SCRIPT_NAME The path that resolved to a file, or / Always empty PATH_INFO The path left after SCRIPT_NAME The whole request path PATH_TRANSLATED The whole request path Not set UNIQUE_ID A random per-connection identifier Not set AUTH_TYPE, REMOTE_USER, REMOTE_IDENT Set to an empty string Not set CONTENT_LENGTH Set to an empty string Set only when the request has a body CONTENT_TYPE Set to an empty string Set only when the request carries a Content-Type header REMOTE_HOST Honours --reverselookup Always the client IP address SCRIPT_FILENAME Not set The resolved path of the script on disk HTTP_HOST Not set The request's Host header HTTP_COOKIE with repeated headers Joined with , Joined with ; PATH when --passenv does not name it Not set /bin:/usr/bin:/usr/ucb:/usr/bsd:/usr/local/bin, a fallback net/http/cgi supplies A --cgidir request path must name the script file exactly. There is no trailing path information to split off, which is why PATH_INFO is the whole path and SCRIPT_NAME is empty. See also The CGI environment explains how a request becomes an environment, and where the trust boundaries fall. Passing data into your script compares the query string with --passenv. CLI flags covers --passenv, --reverselookup, --cgidir, and --dir. ======================================================================== Exit codes https://websocketd.com/docs/reference/exit-codes/ ======================================================================== A running websocketd server has no finished state: it exits only when something outside it stops it, or when it never got started. Every code below is returned before or during startup. Code Meaning 0 --version, --license, or --help was given. The output is printed and the server never starts. 1 A flag value or a combination of flags was rejected. 2 The command line could not be parsed at all, for example an undefined flag. 3 Configuration was accepted, but a listener could not be started. 4 --devconsole was combined with --staticdir or with --cgidir. Which stream carries the message Trigger Stream --version and --license output stdout --help output stderr Command line arguments are missing. (no arguments at all) stdout Incorrect loglevel flag ... stdout Every other exit code 1 message stderr The usage summary printed after most exit code 1 messages stderr The exit code 2 parse error and usage summary stderr The exit code 3 and exit code 4 messages stdout, as log lines The exit code 3 and 4 messages go to stdout because they are written through websocketd's log, which writes every line to stdout. Being log lines makes them the only messages on this page that --loglevel=none silences. The exit code is still 3 or 4, but nothing is printed on either stream. Every other message here is written directly rather than logged, so --loglevel does not affect it. The origin-policy security warning printed at startup is the exception: it goes to stderr, whatever --loglevel is set to. What triggers exit code 0 --version prints the process name and version. --license prints the process name, version, and the BSD licence text. --help prints the extended help. What triggers exit code 1 Each of these is caught before any listener is opened. Trigger Message No command line arguments at all Command line arguments are missing. No COMMAND and none of --dir, --staticdir, --cgidir Please specify COMMAND or provide --dir, --staticdir or --cgidir argument. --loglevel is not one of debug, trace, access, info, error, fatal, none Incorrect loglevel flag ... --ssl without both --sslcert and --sslkey please specify both --sslcert and --sslkey when requesting --ssl --sslcert or --sslkey without --ssl you should not be using --ssl* flags when there is no --ssl option --sslca without --ssl --sslca requires --ssl (mutual TLS has no effect without a TLS listener); add --ssl with --sslcert and --sslkey, or drop --sslca --binary together with --passstderr please only specify one of --binary and --passstderr A negative --maxframesize --maxframesize must not be negative; use 0 for unlimited --socketmode=0 --socketmode 0 would make the socket unusable; pick a mode like 0700 --socketmode above 0777 --socketmode "1000" has bits beyond permission bits (keep it within 0777) --socketmode that is not octal --socketmode "abc" is not an octal permission mode (e.g. 0700) --anyorigin together with --sameorigin or --origin --anyorigin means 'accept any origin' and cannot be combined with --sameorigin or --origin, which restrict it COMMAND not found on PATH unable to locate specified COMMAND '...' in OS path Both COMMAND and --dir given ambiguous: provided COMMAND and --dir argument, please only specify one --dir does not exist could not find your script dir '...' --dir exists but is not a directory did you mean to specify COMMAND instead of --dir '...'? --dir cannot be resolved to an absolute path could not resolve absolute path to dir '...' --cgidir is not an accessible directory your CGI dir '...' is not pointing to an accessible directory --staticdir is not an accessible directory your static dir '...' is not pointing to an accessible directory What triggers exit code 2 Go's flag package rejected the command line before websocketd looked at it: an undefined flag, or a value the flag's type cannot parse. The message is Go's, for example flag provided but not defined: -nosuchflag. What triggers exit code 3 A listener failed to start after configuration was accepted. websocketd exits on the first listener that fails, so one failing address stops the whole server. Causes include: The TCP address is already in use. The --redirport listener could not bind. The --unixsocket path already has a socket file with a live server behind it. A socket file with nothing listening is treated as stale, removed, and rebound. The --unixsocket file could not be chmodded to --socketmode. The --sslca file could not be read, or held no parseable certificate. What triggers exit code 4 --devconsole with --staticdir, or --devconsole with --cgidir. All three serve the same non-WebSocket HTTP surface, so only one of them may be given. See the dev console . See also CLI flags describes what each flag above does. Platform support confirms these codes are the same on every platform. ======================================================================== Reference https://websocketd.com/docs/reference/ ======================================================================== Exact answers about what websocketd does. Every command line flag and its default, every environment variable a spawned process receives, every exit code the binary returns, what --devconsole serves, which platforms are built and released, and what ships in the repository's examples/ directory. One rule applies to every flag rather than to any single one. Flag placement Every websocketd flag must come before COMMAND on the command line, or before --dir, --staticdir, or --cgidir when one of those is used instead of a command. websocketd --port=8080 --header=X-Trace:1 ./myscript.sh websocketd parses its command line with Go's standard flag package, which stops parsing at the first argument that is not a flag. That argument is COMMAND. Everything after it is passed through to the wrapped program as its own argument, unexamined. So this passes --header=X-Trace:1 to myscript.sh and sets no header at all: websocketd --port=8080 ./myscript.sh --header=X-Trace:1 websocketd reports no error in that case, because a wrapped program is entitled to any arguments you give it. The flag is simply never applied. This has been reported several times as --header not working (issues #151, #155). It applies to every flag, not only the header flags. Pages CLI flags lists every flag, its default, and its exact effect. Environment variables lists every variable a spawned process receives, and which of them the client controls. Exit codes gives every code websocketd returns and what triggers it. Dev console covers what --devconsole serves and the contract its response follows. Platform support names the released platforms, and what differs on Windows. Examples covers what ships in examples/, per language, and the command to run each one. For the reasoning behind any of it, see the process model , message framing , and the security model . To wrap a script for the first time, start with the tutorial . ======================================================================== Dev console https://websocketd.com/docs/reference/dev-console/ ======================================================================== --devconsole makes websocketd answer every non-WebSocket HTTP request with one built-in HTML page: a WebSocket client that connects to an endpoint you type, sends messages, and shows every frame in both directions. websocketd --port=8080 --devconsole ./myscript.sh The same page in dark mode: A short screen recording of a session is at console-demo.mp4 . What the page contains Element Behaviour Address bar The WebSocket URL to connect to, prefilled from the page's own location.href. Pressing Enter connects. Connect button and status Connects and disconnects; the status reads DISCONNECTED, CONNECTING, or CONNECTED. Frame list One row per frame, with timestamp, direction, size, and a preview of the message. Selecting a row opens it in the inspector. Inspector The selected frame's opcode, size, and timing, with Pretty, Raw, and Hex views and a Copy button. Composer A text area. Enter sends, Shift+Enter inserts a newline, and Up recalls what you sent before. Counters Frames sent, frames received, total traffic, and connection duration. Auto-reconnect A checkbox. When it is ticked, a closed connection is reopened one second later. Theme button Cycles auto, light, and dark. The choice is remembered in the browser. Clear button Empties the frame list. Nothing about the page is configurable. It is a single HTML file with its CSS and JavaScript inline, compiled into the binary with //go:embed. Mutual exclusions --devconsole cannot be combined with --staticdir or with --cgidir. All three answer the same non-WebSocket HTTP surface. websocketd exits with code 4 if two of them are given, whichever pair it is. Response contract Property Value Request path Every path. The console answers / and any other non-WebSocket request. Body A constant. No part of the request is interpolated into it; the console derives its WebSocket URL in the browser from location.href. Content-Type text/html; charset=utf-8 X-Content-Type-Options nosniff ETag A strong validator: the first 16 bytes of the SHA-256 of the response body, hex-encoded and quoted. A conditional request carrying it is answered 304 Not Modified. Last-Modified The server's startup time. Content-Security-Policy See below. The policy is computed at process start from the page that is actually served, so the hashes cannot go stale: default-src 'none'; script-src 'sha256-...'; style-src 'sha256-...'; connect-src ws: wss:; frame-ancestors 'none' Directive Effect default-src 'none' Nothing loads unless a directive below allows it. script-src One sha256- hash per inline <script> block, over that block's exact bytes. There is no 'unsafe-inline', so a script whose bytes changed does not run. style-src The same, one hash per inline <style> block. connect-src ws: wss: WebSocket connections to any host, which is what the console exists to make. frame-ancestors 'none' The page cannot be embedded in another page's frame. If the embedded page ever contained no inline <script> or no inline <style>, websocketd panics at startup rather than serving a console whose policy would block it. See also Debug a script with the dev console drives an endpoint before you write any client code. CLI flags covers --devconsole, --staticdir, and --cgidir. Exit codes says what code 4 means. ======================================================================== Platform support https://websocketd.com/docs/reference/platform-support/ ======================================================================== websocketd ships released binaries for Linux, macOS, and Windows. It builds from source on any platform Go supports. Three behaviours differ on Windows. Released binaries Each release ships a zip archive per platform, containing the binary, README.md, LICENSE, and CHANGES. Operating system Architecture Archive name suffix Linux amd64 linux_amd64 Linux 386 linux_386 Linux arm linux_arm Linux arm64 linux_arm64 macOS amd64 darwin_amd64 macOS arm64 (Apple silicon) darwin_arm64 Windows 386 windows_386 Windows amd64 windows_amd64 The linux_arm binary is built for ARMv5, so it runs on every Raspberry Pi model. Two Linux package formats are built alongside the archives: .deb for i386 and amd64, and .rpm for i386 and x86_64. FreeBSD, OpenBSD, and Solaris are not release targets. The source compiles for those platforms with go build, and --passenv's built-in default carries entries for several of them, but no archive ships. Building from source go build at the repository root produces a websocketd binary. The minimum Go version is the one named in go.mod. What differs on Windows Each entry below is a property of the Windows platform, not of websocketd, and none of them has a fix pending. SIGINT and SIGTERM are never delivered On Unix, websocketd tears a process down in four steps: it closes the process's stdin, then sends SIGINT, then SIGTERM, then SIGKILL, pausing between each. On Windows, only the last step has any effect. Go's os.Process.Signal implements exactly one signal on Windows, os.Kill, which calls the Win32 TerminateProcess. Every other signal returns "not supported by windows". websocketd logs that error and moves on to the next step, so a program running under websocketd on Windows receives no SIGINT and no SIGTERM, and gets no chance to shut down gracefully (issues #298, #362). A process group is the second difference. On Unix, each wrapped process gets its own process group, signals go to the whole group, and a final SIGKILL sweeps whatever is left in it, so descendants the program started are torn down with it. Windows has no process-group equivalent here, so websocketd signals the direct child only. Any process the wrapped program started outlives it unless the program terminates it. Stdin closing works identically on both platforms. A program that needs a teardown hook on Windows has stdin EOF and nothing else. Shebang lines have no effect A shebang is the #!/usr/bin/env python3 line at the top of a script. On Unix the kernel reads it and runs the named interpreter. Windows has no such mechanism; the line is inert text even when present. For websocketd to launch a .bat, .cmd, or .ps1 file directly, as COMMAND or through --dir or --cgidir, that extension must be associated with its interpreter at the operating-system level, the way Windows normally runs .bat and .cmd through cmd.exe and .ps1 through PowerShell. A script that runs when double-clicked can still fail under websocketd if the association is not present in the environment websocketd itself runs under (issues #384, #454). PATH lookup can resolve a different binary Given a bare command name rather than a full path, websocketd resolves it against PATH. A shell may resolve the same name differently, through an alias, a function, or a shim that the shell knows about and a plain PATH lookup does not. websocketd then launches a different program than the one tested in the shell (issue #372). Passing the full path to the executable removes the lookup. See also Run Windows scripts gives the steps for .bat, .cmd, and .ps1. Process lifecycle has the full teardown sequence these signal differences apply to. Exit codes are identical on every platform. ======================================================================== Examples https://websocketd.com/docs/reference/examples/ ======================================================================== The repository's examples/ directory holds small programs in twenty subdirectories, one per language or platform. Most of them carry the same three programs, so the same idea can be compared across languages. Program Behaviour greeter Reads a line from stdin and writes one line back. Most implementations answer Hello <line>!. count Writes the numbers 1 to 10 to stdout, half a second apart, then exits. dump-env Writes the CGI environment variables websocketd set for the connection, then exits. Every command below is run from inside the example's own directory, and assumes websocketd is on your PATH. --devconsole serves the dev console at http://localhost:8080/, so you can drive the program without writing a client. Unix scripting languages Directory Programs Command bash greeter.sh, count.sh, dump-env.sh, chat.sh, send-receive.sh websocketd --port=8080 --devconsole ./greeter.sh python greeter.py, count.py, dump-env.py websocketd --port=8080 --devconsole python3 -u ./greeter.py ruby greeter.rb, count.rb, dump-env.rb websocketd --port=8080 --devconsole ruby ./greeter.rb perl greeter.pl, count.pl, dump-env.pl websocketd --port=8080 --devconsole perl ./greeter.pl php greeter.php, count.php, dump-env.php websocketd --port=8080 --devconsole php ./greeter.php lua greeter.lua, json_ws.lua, json.lua websocketd --port=8080 --devconsole lua ./greeter.lua Two of the bash programs have no counterpart elsewhere: chat.sh is a multi-user chat server. Each connection appends to a shared chat.log in the working directory, and every other connection tails it. It requires GNU tail, which is not the tail macOS ships. send-receive.sh reads input on a short timeout while emitting a line every few seconds, so both directions are active at once. The Python programs carry a #!/usr/bin/python shebang, a path that many systems no longer have. Running them as python3 -u ./greeter.py uses the interpreter you have and turns off output buffering; see Python scripts . lua/greeter.lua echoes each line back unchanged. lua/json_ws.lua answers with a JSON object wrapping the line, and needs json.lua in the same directory. Compiled and runtime languages Directory Programs Command nodejs greeter.js, count.js websocketd --port=8080 --devconsole node greeter.js rust greeter.rs, count.rs, dump-env.rs rustc greeter.rs then websocketd --port=8080 --devconsole ./greeter haskell greeter.hs, count.hs websocketd --port=8080 --devconsole ./greeter.hs swift greeter.swift, count.swift websocketd --port=8080 --devconsole ./greeter.swift java Echo/Echo.java, Count/Count.java, each with a launcher script cd Echo then websocketd --port=8080 --devconsole ./echo.sh hack greeter.hh, count.hh, dump-env.hh websocketd --port=8080 --devconsole hhvm greeter.hh qjs request-reply.js websocketd --port=8080 --devconsole qjs --module request-reply.js nodejs/greeter.js answers each line with data: <line> rather than a greeting. qjs/request-reply.js answers with RCVD: <line>. The Java launcher scripts compile before running: echo.sh runs javac Echo.java and then java Echo, so a JDK must be on the PATH. The .java files and their launchers live in the Echo/ and Count/ subdirectories, not in examples/java/ itself. The Haskell scripts have a #!/usr/bin/env runhaskell shebang and the executable bit, so websocketd can launch them directly. The Swift scripts do the same through xcrun, which exists on macOS only. Windows Each of these programs has a .cmd launcher next to it, and the .cmd file is what websocketd runs, because Windows does not read shebang lines . Directory Programs Command powershell greeter.ps1, count.ps1, dump-env.ps1, each with a .cmd launcher websocketd --port=8080 --devconsole greeter.cmd windows-jscript greeter.js, count.js, dump-env.js, run by Windows Script Host, each with a .cmd launcher websocketd --port=8080 --devconsole greeter.cmd windows-vbscript greeter.vbs, count.vbs, dump-env.vbs, run by Windows Script Host, each with a .cmd launcher websocketd --port=8080 --devconsole greeter.cmd csharp Echo and Count Visual Studio projects, Examples.sln, run_echo.cmd, run_count.cmd Build Examples.sln, then run run_echo.cmd fsharp Echo and Count Visual Studio projects, Examples.sln, run_echo.cmd, run_count.cmd Build Examples.sln, then run run_echo.cmd The PowerShell .ps1 files also carry a #!/usr/bin/env pwsh shebang, so on Linux and macOS they can be run as websocketd --port=8080 --devconsole pwsh ./greeter.ps1. The run_echo.cmd and run_count.cmd launchers already contain the full websocketd command line, including --port=8080 and --devconsole. Not WebSocket programs Directory Contents Command cgi-bin dump-env.sh, served over plain HTTP as CGI websocketd --port=8080 --cgidir=., then curl http://localhost:8080/dump-env.sh html count.html, a browser client for the count programs Start a count program on port 8080, then open the file in a browser cgi-bin/dump-env.sh prints the variables a CGI request receives, which differ from the WebSocket set; see environment variables . See also CLI flags covers --devconsole, --dir, and --cgidir. The tutorial walks through wrapping your own script for the first time. Python scripts fixes output that does not appear. ======================================================================== The process model https://websocketd.com/docs/understanding/process-model/ ======================================================================== websocketd starts one fresh instance of your program for every WebSocket connection, and tears that instance down when the connection closes. Two browser tabs mean two processes. A thousand connections mean a thousand processes. Nothing is shared between them. Almost every other question about websocketd resolves to this one fact. Understand it before you design anything on top of it: the model is unusually generous in one direction and completely closed in the other. What the model gives you Your program does not have to know that websocketd exists. It reads lines from stdin and writes lines to stdout, which is the oldest and most portable interface in computing. That is the whole contract. The consequences are larger than they first look: Any language works, with no library. There is no websocketd client library for Python, no gem for Ruby, no npm package, and there never needs to be. If the language can print a line, it can serve a WebSocket connection. That is why the examples span a dozen languages with no shared code between them. You can test without a browser. A program that talks over stdin and stdout can be run directly in a terminal and driven by typing at it. If it behaves there, it will behave under websocketd. Debugging a misbehaving endpoint usually means running the program by hand first, which is what makes debugging a script tractable. One connection cannot corrupt another. A crash, a memory leak, an infinite loop, or a wild pointer is contained inside one operating system process. The other connections do not notice. You get this isolation for free, without writing a single line of defensive code, and without the careful state hygiene a shared-process server demands. Your program can be stateful without being careful. Inside a single connection, your script owns the world. Global variables, open files, accumulated buffers: none of it needs locking, because nothing else is looking at it. Concurrency bugs are the class of bug this model deletes outright. What the model takes away There is no broadcast. There is no shared state. There is no cross-connection anything. Concretely: a variable your script sets while serving one connection does not exist in any other connection's process. There is no built-in publish/subscribe, no "send this message to every connected client", no registry of who is currently connected, and no shared in-memory store. websocketd does not keep a list of live connections that your program can reach, because your program is not the kind of thing that could reach one. This is the most common surprise websocketd springs on people, and it is not a gap waiting to be filled. The reasons are in design decisions . There is also no singleton. No process is running when websocketd starts; the first one appears when the first client connects. If you were hoping for one long-lived process that all clients talk to, that is the opposite of what websocketd does. See process lifecycle for exactly when processes appear and disappear. The cost of a process A process is heavier than a thread and much heavier than an async task. Every connection costs a fork, an exec, three pipes, and whatever memory your program's runtime demands at startup. A Python interpreter is tens of megabytes; a small C program is a rounding error. The model's cost is therefore mostly your program's cost, not websocketd's. That cost is bounded on purpose. websocketd refuses to fork past --maxforks, which defaults to 1024 concurrent processes, and answers further upgrade requests with 429 Too Many Requests until one finishes. The default is a backstop against a client that opens connections in a loop, not a capacity recommendation. If you need more, raise it deliberately, having first worked out what a thousand copies of your program cost on that host. The exact behaviour is in the flag reference . This model suits tens or hundreds of concurrent connections running programs you wrote, on a host you control. It is not built to hold a hundred thousand idle sockets. If that is your problem, you want a purpose-built server, and websocketd will tell you so by running out of processes rather than by degrading quietly. Where shared state goes The model does not stop you from building a chat room or a live dashboard. It moves the shared part somewhere else, which is where it was always going to have to live once you had more than one host anyway. The usual shapes are an external message bus that already knows how to fan one message out to many subscribers, a shared file that several processes on the same host append to and follow, or a single long-lived backend service that the per-connection scripts act as thin clients for. Choosing between them is a real decision with real trade-offs, worked through in sharing state across connections . Next Message framing covers what crosses the stdin and stdout boundary. Process lifecycle covers when the process starts and how it is shut down. Design decisions covers why this model was kept, including the process pool that was built and then removed. ======================================================================== Message framing https://websocketd.com/docs/understanding/message-framing/ ======================================================================== A WebSocket connection carries discrete messages. A pipe carries an undifferentiated stream of bytes. Something has to decide where one message ends and the next begins, and in websocketd that something is the newline character. The newline is the boundary By default, websocketd frames in both directions on \n: Going in, each WebSocket message the client sends is written to your program's stdin with a newline appended. Your program can read it with an ordinary line read. It never sees WebSocket framing, message types, masking, or any other protocol detail. Coming out, websocketd reads your program's stdout until it sees a newline, then sends everything up to that point as one WebSocket message. The newline itself is stripped. A trailing \r\n is stripped as a unit, so a program written on Windows does not leak carriage returns into the message. The second half is a hard rule, not an optimisation. Bytes your program has written but not yet terminated with a newline are not sent. They sit in websocketd's reader until a newline arrives, and then they go out as one message together with whatever followed. A program that writes a progress bar by printing characters without newlines will appear to send nothing at all, then send the entire bar at once when it finally prints one. This trap has a twin, and the two compound. The framing rule is about whether websocketd has seen a newline; output buffering is about whether your program's runtime has handed websocketd the bytes at all. A line that is buffered inside your program's runtime is invisible to websocketd no matter how many newlines it contains, and a flushed line with no newline is invisible for the other reason. When output is not arriving, rule out both, starting with buffering, which is by far the likelier of the two. What --binary changes --binary changes two mechanical things, and nothing else. It changes the WebSocket message type. Messages are sent and received as binary frames rather than text frames. Your client code can see this directly: in a browser, event.data arrives as a Blob or ArrayBuffer rather than a string. It removes the newline rule. Outbound, websocketd forwards whatever bytes it reads from your program's stdout as soon as it reads them, in chunks of up to 64KB, which is the most a single read from a pipe can return. Inbound, the bytes a client sent are written to stdin exactly as they arrived, with no newline appended. So --binary is not cosmetic. If you need to move images, audio, or protocol buffers through websocketd, it is the flag that lets you do it without base64 in the middle. What --binary does not change It does not give your program a terminal. This is the misreading people arrive with. --binary looks like a "raw mode" switch, and raw mode in a terminal emulator means keystroke-at-a-time input with character-by-character echo. People reach for --binary expecting to pipe individual keypresses into a shell or a curses application and get a live terminal in the browser. That is not what happens, and no combination of flags makes it happen. websocketd never allocates a pty. A pty, short for pseudo-terminal, is a kernel device pair that pretends to be a terminal: it is what makes a program believe a human with a keyboard is on the other end. websocketd gives your program three ordinary pipes instead, one each for stdin, stdout, and stderr, in binary mode exactly as in text mode. Programs notice the difference, because they are designed to. A program that checks whether its output is a terminal and finds a pipe will usually change behaviour: it will switch to block-buffered output, drop colour, refuse to run interactively, or abandon the keystroke-at-a-time input model it would use on a real terminal. That check is made by the program, inside its own process, and --binary changes nothing it can see. The permanent consequence is that programs which require a real terminal do not work through websocketd. vim, less, screen, watch, and docker run -it are all in this category. Keystroke-at-a-time input, with no newline to end each message, is not supported and is not planned. The reasoning is in design decisions . The two modes do not mix websocketd reads only the message type it is configured for. A binary frame sent to a text-mode server is discarded, and so is a text frame sent to a --binary server. Nothing is reported to the client, and the discard is recorded only at debug log level. That silence is easy to misread. If a client's messages are vanishing with no error anywhere, check that client and server agree on the message type before investigating anything else. The dev console shows you the frames as they arrive, which settles the question quickly. Message size, and what happens at the limit An inbound message larger than --maxframesize, which defaults to 1 MiB, is not truncated and not ignored. websocketd closes the connection with WebSocket close code 1009, "message too big". Your program then sees its stdin close and is shut down through the usual teardown ladder . The limit exists because websocketd has to hold an inbound message in memory before it can write it to a pipe, and an unbounded message is an unbounded allocation driven by whoever is connecting. It is a backstop, not a sizing decision. Setting it to zero removes the limit, and removes that memory bound along with it. There is no compression. permessage-deflate is not implemented, so every message goes over the wire uncompressed. Next Output buffering explains the other half of the "nothing is arriving" problem. The flag reference has the exact defaults for --binary and --maxframesize. Debug a script shows how to watch real frames instead of guessing. ======================================================================== Output buffering https://websocketd.com/docs/understanding/output-buffering/ ======================================================================== Your program works in a terminal. Under websocketd it sends nothing, or sends everything at once when it exits. Nothing in websocketd changed between those two runs, and nothing in your program changed either. What changed is what your program's stdout is connected to, and your language's runtime quietly changed its own behaviour in response. This is the most common problem people have with websocketd, by a distance. It is also not a websocketd problem, which is exactly why it is so confusing: every part of the system is behaving as designed. The three words you need A pipe is a one-way channel between two processes, provided by the operating system. One process writes bytes in at one end, another reads them out at the other. websocketd creates three pipes for every program it launches, one each for stdin, stdout, and stderr, and reads your output from the middle one. A pipe is not a terminal and does not pretend to be one. Line-buffered means the runtime collects the characters you print until it sees a newline, then writes the whole line to the operating system in one go. Output appears line by line, at roughly the moment your code produced it. Block-buffered, sometimes called fully buffered, means the runtime collects output until it has accumulated a fixed-size block, commonly four or eight kilobytes, and only then writes it out. Newlines are not special. Output appears in bursts of several kilobytes, or, for a program that never produces that much, only when the program exits and the runtime flushes what is left. Why the runtime switches modes This behaviour predates the web by decades and was, at the time, an obviously good idea. Writing to the operating system is comparatively expensive. A program that prints a million short lines and asks the kernel to handle each one separately does a million system calls. Buffering in user space collapses that into a few hundred, which is a large win for anything whose output is destined for a file or another program. Interactivity is the exception. If a human is watching the output, the program has to give up that win, because output that arrives eight kilobytes at a time is useless to a person waiting for a prompt. So the C standard library adopted a rule that nearly every language runtime since has inherited: stdout is line-buffered when it refers to an interactive device, and block-buffered otherwise. The rule leaves stderr unbuffered, on the reasoning that error messages must not be lost in a buffer when the program crashes. The runtime decides which case it is in exactly once, when the stream is first set up, by asking the operating system whether the file descriptor is a terminal. Under websocketd it is a pipe, so the answer is no, so block buffering it is. Your program was never consulted. The effect is easy to see. Take a script that prints three lines half a second apart and run it twice: once with its stdout connected to a pipe, and once with its stdout connected to a pseudo-terminal. Through the pipe, all three lines arrive together at 1.64 seconds, when the process exits. Through the pseudo-terminal, they arrive at 0.01, 0.55, and 1.10 seconds, as they are printed. Same interpreter, same script, no flags, no configuration. Only the far end of the file descriptor differs. Why websocketd cannot fix it for you The buffer is inside your program's address space. It belongs to your program's runtime library, not to the operating system and not to websocketd. From the outside, a program holding eight kilobytes of your output in a private array is indistinguishable from a program that has not produced any output yet. There is nothing for websocketd to read, and no way for it to ask. There is one thing websocketd could do that would change the answer: it could allocate a pty, a pseudo-terminal, so that the runtime's is-this-a-terminal check came back true and line buffering stayed on. It deliberately does not, and that decision has consequences well beyond buffering. It is discussed in design decisions , and its other effects are described in message framing . Since websocketd cannot reach into your program, the fix has to be inside it. Every language provides a way to say either "flush this now" or "never block-buffer this stream in the first place", and it is usually one line or one command-line flag. Which line, in which language, is the whole content of the how-to pages: Python , Ruby , Node.js , PHP , C , and Windows scripts . Two independent gates Buffering is the first of two things that must go right before a line reaches the browser, and it helps to keep them separate in your head. The first gate is your runtime: has it written the bytes to the pipe? That is this page. The second gate is websocketd: has it seen a newline yet? websocketd sends a WebSocket message only when it reads one, as described in message framing . Both gates must open. A line that is flushed but has no trailing newline is held by websocketd. A line that has a newline but is still sitting in your runtime's buffer has never reached websocketd at all. The symptom, silence in the browser, is identical either way, which is why so many reports of one turn out to be the other. There is a useful asymmetry when you are diagnosing this: buffering explains almost all real cases, and it has a distinctive signature. If everything appears in a burst the moment the program exits, or in large clumps at irregular intervals, that is a block buffer draining. If nothing ever appears, not even at exit, look at the newline instead. A note on stderr By convention stderr is unbuffered, so it escapes this problem entirely. That is useful while you are diagnosing: a program that prints diagnostics to stderr will show them in websocketd's log immediately, even while its stdout sits in a buffer. The contrast between the two streams is itself a strong signal that you are looking at a buffering problem. Next Message framing covers the second gate, the newline rule. The language how-to pages have the actual fix, per language. Debug a script shows how to watch what is really arriving. ======================================================================== Process lifecycle https://websocketd.com/docs/understanding/process-lifecycle/ ======================================================================== A wrapped program's life is bounded exactly by one WebSocket connection. It does not exist before the connection opens, and websocketd works hard to make sure it does not exist after the connection closes. The second end is where a long-running program can go wrong. Nothing runs until someone connects When websocketd starts, it binds a socket and waits. It does not launch your program. Run ps immediately after starting websocketd and you will find websocketd and nothing else. The first instance of your program appears when the first client completes a WebSocket handshake, and it is created for that client alone. This is deliberate, not a scheduling delay, and it follows directly from one process per connection : there is no such thing as a warm process waiting for a client, because a process only means anything in the context of the connection it serves. Two practical consequences follow. Startup errors in your program are not startup errors in websocketd, so a script with a broken shebang line or a missing interpreter will start websocketd cleanly and fail on first connect. And any expensive initialisation your program does at startup is paid once per connection, not once per server. The teardown ladder When the connection closes, websocketd does not immediately kill your program. Killing a program outright denies it the chance to flush a file, commit a transaction, or release a lock. Instead websocketd asks progressively less politely, waiting after each request, and stops the moment the process is reaped. It begins by closing your program's stdin. For a great many programs this is sufficient on its own: a script whose main loop reads lines until end of input will simply run off the end and exit. If that happens within about 100 milliseconds, nothing further is sent. If the program is still running after that wait, websocketd sends SIGINT, the signal a terminal delivers on Ctrl+C, and waits about 250 milliseconds. If it is still running, websocketd sends SIGTERM, the conventional "please shut down" signal, and waits about 500 milliseconds. If it is still running, websocketd sends SIGKILL, which cannot be caught, blocked, or ignored, and waits up to 1000 milliseconds for the kernel to finish the job. If even that does not produce a reaped process, websocketd logs an error and stops waiting. Whichever rung it reaches, and including the fast path where the program exits immediately on end of input, websocketd finishes by sending a final SIGKILL to the whole process group. Nothing is left behind because teardown ended early. --closems lengthens the first three waits, and only those three. It adds its value to the 100, 250, and 500 millisecond waits, and does not touch the final 1000 millisecond wait for SIGKILL to take effect. Raise it when your program needs longer than a fifth of a second to notice a signal and clean up after itself, which is common for anything that has to finish a network round trip on the way out. Why a long-running program must handle signals If your program is a short script that exits when its input runs out, none of the ladder above will ever be visible to you. If your program loops forever, ignores signals, and does not read stdin, the picture is different. Closing stdin tells it nothing, because it is not reading. SIGINT and SIGTERM both have a default action of terminating the process, so an unhandled signal will still stop it. The trouble is the program that installs a handler and then does nothing useful in it, or that blocks the signal, or that is stuck in a call that will not return. Such a program rides the ladder to the bottom on every single disconnect and is killed with SIGKILL, which means it never gets to flush anything, ever. So if your program is long-running rather than a script that finishes on its own, handle SIGINT and SIGTERM and exit promptly on either. That is the entire contract. It costs a few lines, and it is the difference between a clean shutdown and a program the operating system kills a fraction of a second later, mid-write. What happens to the children your program spawns On Unix, websocketd puts each wrapped program into its own process group, using setpgid. A process group is a set of processes the kernel can signal as a unit. Children your program spawns inherit its group, which means the SIGINT, SIGTERM and SIGKILL of the ladder reach them too, and the final sweep catches anything still alive in the group when the direct child is gone. Your program does not have to forward signals for this to work. Only the stdin close is specific to the direct child. It is a pipe, and only the program on the other end of it can notice. The consequence to plan for is the reverse one. A process that is supposed to outlive the connection has to leave the group deliberately, by starting a new session with setsid or an equivalent. Anything still in the group when the connection ends is killed. This is the standard Unix opt-out and it is intentional: the default is that a connection cleans up completely after itself. On Windows the picture is smaller. Windows has no process groups to signal, and no mechanism for delivering SIGINT or SIGTERM to another process at all, so those rungs of the ladder fail and are logged. Windows teardown is effectively "close stdin, wait, then terminate the direct child", and there is no group sweep. A child your program started on Windows keeps running unless your program stops it itself. The details are in platform support . When the connection is closed by websocketd Not every teardown starts with the client. websocketd closes the connection itself when an inbound message exceeds --maxframesize, when --pingms keepalives go unanswered for twice the ping interval, and when your program's stdout reaches end of file, which usually means the program exited on its own. The ladder is the same in all of those cases: from the program's point of view, teardown always looks like stdin closing, followed by escalating signals. Next The process model explains why there is one process per connection in the first place. The flag reference has --closems, --maxframesize, and --pingms with their exact defaults. Run a program once, or keep it running covers the shapes a wrapped program can take. ======================================================================== Understanding websocketd https://websocketd.com/docs/understanding/ ======================================================================== websocketd is small enough that you can hold all of it in your head, and these pages are how you get there. They explain the reasoning behind the behaviour rather than telling you which command to type. Read this section when something websocketd does surprises you and you want to know whether it is a bug, a setting, or the design. Read it before you build anything larger than a demo, because two of the decisions here (one process per connection, and newline-delimited framing) shape what your application can be. If you have a job to finish right now, the how-to guides give steps rather than reasons. If you want an exact value for a flag, a variable, or an exit code, it is in the reference . If you are new to websocketd, start with the tutorial and come back. ======================================================================== The CGI environment https://websocketd.com/docs/understanding/cgi-environment/ ======================================================================== Your program has no way to ask websocketd about the request that started it. It has stdin, stdout, and command-line arguments that are the same for every connection. So websocketd puts the request where any program in any language can already read it without a library: the environment. The convention it follows is the Common Gateway Interface, RFC 3875 , which web servers have used to talk to child processes since 1993. CGI is not fashionable, but it is the only request-passing convention that every language can already read, because every language can read an environment variable. os.environ, ENV, getenv, $_SERVER, process.env: the same variables, no adapter. It is the same reasoning that put stdin and stdout at the centre of the process model . The shape of the contract The environment is built once, when the connection is accepted, and handed to the child at exec time. It is a snapshot, not a channel. Nothing that happens later in the connection changes it, and your program cannot signal back through it. Everything dynamic goes over stdin and stdout. Three kinds of thing end up in it. There are facts about websocketd itself, such as its version. There are facts about the request, derived from the URL, the connection, and the headers. And there are variables inherited from the environment websocketd was started in, but only the ones you named. That last category is an allowlist, not a passthrough, and it is the most important structural fact on this page. On every platform except Windows, websocketd wipes its own environment at startup, having first copied out the variables --passenv names. Nothing your program sees was inherited by accident. If you start websocketd from a shell holding a database password in an environment variable, that password does not reach your script unless you asked for it by name. The exact list of variables, what populates each one, and how the --cgidir set differs, is in the environment variable reference . What the client controls Some of these variables describe the server. Others describe the client. The distinction matters because the client writes its own half. SERVER_NAME and SERVER_PORT sound like server facts and are not. They are derived from the request's Host header, which is a string the client chose. Anything that can open a connection can put anything it likes there. A request arriving on port 8080 can claim Host: admin.internal:443, and your script will see exactly that in SERVER_NAME and SERVER_PORT. This is not a defect, and it is not going to be changed. Host-derived values are what virtual hosting requires and what every CGI server does, including Go's own net/http/cgi. It is documented and accepted behaviour. What it means for you is a rule with no exceptions: never make a trust or access-control decision from SERVER_NAME or SERVER_PORT. If your script needs to know which interface or port it is really serving, that comes from your deployment configuration, which you control, not from the request, which you do not. The same caution applies more obviously to QUERY_STRING and to the HTTP_ variables built from request headers. Those are unambiguously client input. Treat them the way you would treat anything arriving from the network, because that is what they are. Header values are lightly normalised on the way in, with newlines and carriage returns replaced by spaces so that a header cannot forge a second environment entry, but their content is otherwise the client's. REMOTE_ADDR and REMOTE_PORT are the exception in the other direction. They come from the socket rather than from anything the client wrote, so they are as trustworthy as your network path. Behind a reverse proxy they describe the proxy. The Proxy header, and why it is special One request header never becomes an environment variable. websocketd drops Proxy before mapping headers into HTTP_ variables, so a client cannot cause HTTP_PROXY to appear in your program's environment. The reason has a name: httpoxy, CVE-2016-5385, disclosed in 2016. The vulnerability sits at the intersection of two conventions that were individually reasonable and catastrophic together. CGI says that a request header Foo becomes the environment variable HTTP_FOO. Quite separately, a long Unix tradition says that a program wanting to make outbound HTTP requests should read the variable HTTP_PROXY to find out which proxy to use, and most HTTP client libraries do exactly that. Put them together and a remote client can send a header called Proxy, which becomes HTTP_PROXY in a CGI script's environment, which the script's own HTTP library then obeys. Every outbound request the script makes, including ones carrying credentials, is routed through a server the attacker chose. The script does nothing wrong; it is asked to by an environment variable it had no reason to distrust. The whole CGI ecosystem was affected in 2016, and the fix everywhere was the same: refuse to map that one header. Go's net/http/cgi carries the same guard, and so does websocketd. The exception matters because it is the one place where the "every header becomes a variable" rule is not true. There is no authenticated user AUTH_TYPE, REMOTE_USER, and REMOTE_IDENT are part of the CGI specification and describe an authenticated caller. In websocketd they are always empty strings. They are set to empty deliberately rather than left out. If they were simply absent, a variable of that name inherited from the parent environment could survive into your script and be mistaken for an authenticated identity. Blanking them means the answer to "who is this user" is always, unambiguously, "websocketd does not know". It does not know because it never asks. websocketd has no built-in authentication at all, by design; that decision, and what to do instead, is covered in the security model and worked through in adding authentication . PATH crosses the boundary on purpose --passenv defaults to passing PATH, along with the platform's shared library search path. Almost every script needs it, because without a PATH even ls is unfindable and most interpreters cannot locate their own helpers. That has a cost. Your PATH describes your machine's directory layout: where you keep binaries, which language version managers you use, sometimes your username in a home directory path. Any code running inside your script can read it. If your script executes anything derived from client input, that includes the client's code. This is a considered default, and you can change it. --passenv replaces the default list rather than adding to it, so naming your own variable drops PATH unless you name PATH too. Two implementations, not one websocketd builds the environment above for WebSocket connections itself. It does not do so for --cgidir, which hands the request to Go's net/http/cgi, and that package builds its own environment to its own rules. Most variables agree. Several do not, SCRIPT_NAME and PATH_INFO among them, and a script that routes on those will behave differently under the two modes. The differences are tabulated in the environment variable reference . These are two separate code paths, so "websocketd sets X" is a claim that needs a mode attached to it. Next Environment variables is the complete table, including the --cgidir differences. The security model covers the trust boundaries this page's warnings imply. Passing data into your script shows how to read the query string. ======================================================================== The security model https://websocketd.com/docs/understanding/security-model/ ======================================================================== websocketd runs a program for anyone who can open a connection to it. That is the feature. It is also the entire security model in one sentence, and everything below is a consequence of taking it seriously. The right way to think about a websocketd endpoint is as a remotely callable program, not as a web page. If the program can delete files, then whoever can connect can delete files. Every question worth asking reduces to who is allowed to connect, and what the program they reach is allowed to do. Who may connect: the origin policy The most important thing to understand about WebSocket connections is that browsers do not protect you from them. The same-origin policy that stops a page on one site from reading another site's data does not apply to WebSocket. A page on any site, open in any browser on any machine that can route to your server, may open a WebSocket connection to it. The browser will send an Origin header saying where the page came from, and will then connect regardless of what your server thinks of the answer. Deciding what to do with that header is entirely the server's job. By default, websocketd accepts every origin. That default exists to make local development frictionless, and it is fully permissive: a websocketd you started to try something out on your laptop can be driven by any web page you happen to visit while it is running. The page cannot see your filesystem, but it can talk to the program you wrapped, and that program can. Because that is easy to miss, websocketd prints a warning about it to stderr every time it starts without an origin policy. The warning goes to stderr rather than into the log stream on stdout, so that redirecting the log to a file still leaves the notice visible on the console. It names the three ways to resolve it. --sameorigin accepts an upgrade only when the Origin header matches the Host of the same request. It is the natural choice when websocketd is the thing the browser talks to directly, including when websocketd serves the client page itself. It is a poor fit behind a TLS-terminating reverse proxy, where the browser's origin implies port 443 and the proxied request implies port 80, so every upgrade is rejected. --origin takes an explicit list of acceptable origins and is matched against the Origin header alone. That makes it the right tool behind a proxy, and the right tool whenever you know the public origin your clients will come from. --anyorigin says you have considered the question and chosen the permissive answer, and silences the warning. It is meaningful precisely because it is a decision. Reach for it when authentication happens in front of websocketd, or when nothing but localhost can reach the port, and not merely to quiet the console. The three are not interchangeable. --anyorigin contradicts the other two and websocketd refuses to start if you combine them. --sameorigin and --origin compose, and an upgrade must then satisfy both. Origin matching is strict about ports, deliberately. An entry naming a port matches that port only. An entry with no port matches only the default port for its scheme. A host is not a blanket endorsement of everything listening on it, because in practice a host runs services of very different sensitivities, and allowlisting a web app should not also allowlist whatever is on the port next door. Where a host really is uniformly trusted, an explicit :* suffix says so. A future release will default to --sameorigin. No date is set. If you depend on today's permissive default, pass --anyorigin now and the change will not move under you. The origin policy is a control on browsers, and a good one, because a browser reliably tells the truth in the Origin header. It is not a control on anything else. A script with a WebSocket library sets whatever origin it likes. Origin policy raises the floor against hostile web pages; it is not authentication. What the connection can reach Once a connection is accepted, it reaches a process, and that process is where your real security boundary lives. That process should run as a user with only the privileges the job needs. Everything arriving on its stdin is attacker-controlled by construction, and so is every request-derived value in the CGI environment , where SERVER_NAME and SERVER_PORT are client-controlled despite their names. Passing any of it to a shell is the classic mistake. websocketd puts backstops around the process rather than around your program's logic, because it cannot know your logic. --maxforks, at 1024 by default, stops a client opening connections in a loop until the host runs out of processes. --maxframesize, at 1 MiB by default, stops a client sending one enormous message that websocketd must hold in memory. Both are limits on abuse, not capacity plans, and neither should be read as advice about how much traffic to expect. When you listen on a Unix domain socket rather than a TCP port, the access control moves to the filesystem, and the socket file's permissions are what decide which local users can connect. websocketd leaves those permissions to your umask unless you say otherwise, which under a permissive umask can leave the socket open to every account on the machine. --socketmode pins them, applied the moment the socket is bound. It changes nothing else, and nothing beyond that file. The main server sets no read or write timeout, on purpose. A timeout on the body of a request would kill exactly the long-lived streaming connections websocketd exists to carry. What it does set is a header timeout, bounding how long a client may take to send its request headers, which is what closes off a slowloris-style attack that opens many connections and dribbles bytes into them forever. Idle connections are a separate matter, handled by --pingms keepalives rather than by a blunt timeout. Transport: TLS --ssl, with --sslcert and --sslkey, serves https:// and wss://. The minimum protocol version is pinned at TLS 1.2 explicitly, rather than inheriting whatever the Go runtime happens to default to this year. --sslca additionally requires every client to present a certificate signed by the named authority, verified during the handshake, before your program is ever launched. That is mutual TLS, and it is the one form of client authentication websocketd does have. It suits machine-to-machine deployments where you control both ends. It does not suit browsers, where certificate provisioning is a real burden. --sslca requires --ssl: websocketd rejects --sslca given without --ssl at startup, with exit code 1, because mutual TLS has no handshake to verify a client certificate in without a TLS listener. Setting up either is covered in serving over wss:// . --redirport opens a second plain HTTP listener whose only response is a 301 to the canonical address, so that a visitor who typed http:// still arrives. The Location it sends keeps the host the client itself named and the path and query it asked for, and rewrites only the scheme and the port. That is what stops it being an open redirect: the path and query are resolved against the canonical origin as a relative reference, and a reference cannot introduce a host of its own, so a request for //evil.com/ redirects to //evil.com/ as a path on your server rather than to another site. The host comes from the client's request, which means a client that names a different host in its Host header, or writes an absolute URL in its request line, is redirected to the host it named. It is redirecting itself, and it could have gone there without asking you, so this is not a way to redirect anybody else. It does mean the redirect listener is a poor place to look for evidence of who visited your canonical address. There is no built-in authentication websocketd authenticates nobody. AUTH_TYPE, REMOTE_USER, and REMOTE_IDENT are always blank for that reason. This is settled, not pending. Authentication is the part of a system most tightly bound to the organisation around it: a session cookie, a bearer token, mutual TLS, an SSO provider, an internal service mesh. Building one of those in would serve a narrow slice of deployments and would put websocketd in the business of storing credentials, which is a different and much less forgiving product than a program that pipes stdin to a socket. The reasoning is expanded in design decisions . There are two good places to put it instead, and which one fits depends on whether you already have a reverse proxy in the path. Put it in front. A reverse proxy that authenticates before forwarding means websocketd only ever sees requests that already passed, and your script does not have to think about identity at all. This is the usual answer for anything facing the public internet. Or put it inside. Your script can read a token from the query string or a request header through the CGI environment, check it, and refuse to do anything useful otherwise. This suits deployments with no proxy, and it has the advantage that the check lives next to the thing it protects. Both are worked through in adding authentication . What --staticdir refuses Serving files is the one thing websocketd does that is not running a program, and it comes with its own refusals. They all follow from one rule: a request is answered only with a plain file that really is inside the directory you named. Each refusal below is a way a file can fail that test. A path with a segment beginning with . is refused, so a .git directory or an .env file sitting in the tree cannot be fetched by name. A directory with no index.html is refused rather than listed, so a directory nobody wrote a page for does not become a public index of its own contents. A file reached through a symlink that leaves the directory is refused, because what decides the answer is where the file really lives, not the path that named it. The one exception is /.well-known/. RFC 8615 reserves that exact directory name for URIs meant to be served publicly, such as an ACME client's /.well-known/acme-challenge/<token> or a security.txt. Refusing it would break a standardised convention with no flag to opt back in, so it is matched on that exact first path segment and served. A dotfile nested deeper inside it, or a directory whose name merely starts with .well-known, is refused like any other. Files you configured to be executed are never handed back as source --dir and --cgidir name directories of programs. What a client is meant to see is a program's output; its source is a different thing, and it routinely carries credentials, internal hostnames, and the shape of the system behind it. So the static handler refuses any file living in either tree and answers 404 instead. That refusal has to be about the file rather than about the URL, because a URL has many more spellings than the layout has directories. A symlink elsewhere in the static tree pointing into the script directory names the same file. So does a differently-cased path on a filesystem that folds case, and so does a path that walks up out of a directory and back down into it. websocketd resolves the file and compares directory identity, so every one of those spellings gets the same answer. With --staticdir=/PAGE --dir=/PAGE/scripts, a browser asking for /scripts/hello.sh sends no Upgrade header, so the request never reaches the WebSocket handler at all. Without this refusal it falls through to the static handler and comes back as the text of the script. A --dir script keeps the URL --dir gives it and gains no second one from where the directory sits. In that same layout /hello.sh reaches the script and /scripts/hello.sh reaches nothing. One layout is exempt: a --staticdir naming one of those directories itself, or a directory inside it. --dir=. --staticdir=. is the oldest demo layout in the project, and excluding the script tree there would leave the static handler with nothing whatsoever to serve. Scripts are still served as source in that layout. If that is not what you want, put the static files and the scripts in separate directories. Where the CGI directory sits decides its URL Nesting the other way round is the natural layout for a self-contained site: --cgidir pointing at a cgi-bin inside the --staticdir root. The URL a browser forms for a script there is /cgi-bin/hello.sh, and websocketd runs the script for it, alongside the /hello.sh that --cgidir has always answered. It works that position out by resolving both directories and asking which directory each flag names, not by comparing the two strings you typed. A release symlink named by one flag and by its real path in the other is one pair of directories however it is spelled, and it routes as one pair. Identity constrains the answer in the other direction too: a --cgidir genuinely outside the static root is not brought inside it by a symlink there. Only the two directories you named are resolved, so a URL reaching the scripts through such a link stays refused. The position is settled once and then reused, which matters if you deploy by swapping a symlink. Flipping it under a running websocketd does not re-route requests. Restart to pick up the new layout. What is still yours to get right None of this vets what you put in the directory in the first place. A secret checked in under a name that does not start with a dot (credentials.txt, say) is served like any other file. The refusals above are about how a file can be reached, not about whether it belonged in a published tree, so --staticdir is not something to point at a home directory and rely on. Point it at a tree that holds only what you mean to publish. The exec-directory exclusion covers the directories you named on the command line, and only those. A second copy of the same scripts elsewhere under --staticdir is an ordinary file as far as websocketd is concerned. The one thing that remains entirely the operator's responsibility is exposure itself. websocketd binds where you tell it to, and binding to a public interface is a decision to accept connections from the internet. If you are about to do that, the exposure checklist is what to work through first. Next The CGI environment has the trust boundaries in the request data your script reads. Adding authentication has the two working patterns. The flag reference has every default named above, exactly. ======================================================================== Design decisions https://websocketd.com/docs/understanding/design-decisions/ ======================================================================== Most of what websocketd does not do, it does not do on purpose. This page collects the reasoning, including the two features that were built and then taken out again. There is one criterion behind nearly all of it. websocketd's value comes from the size of the contract it asks your program to honour: read lines from stdin, write lines to stdout. Every feature proposed here would have added something to that contract, or added a concept your program would eventually have to know about. Staying small is not modesty. It is the reason a shell script and a Haskell program can both be WebSocket servers with no adapter in between. Why not broadcast The most requested feature, over more than a decade, is a way to send one message to every connected client. The problem is not that it is hard. The problem is that "broadcast" turns out to be at least four different features wearing one word. A chat room wants messages fanned out to a named room with membership and history. A monitoring dashboard wants the latest value pushed to whoever happens to be watching, with no history at all and no need to deliver anything to a client that missed it. A multiplayer game wants ordered, low-latency delivery with per-client filtering. An administrative tool wants one message to one other session. Each of those needs different guarantees about ordering, durability, membership, and what happens to a client that reconnects. Any built-in mechanism would have to pick one set, and would then be wrong for the other three while still being in everyone's way. Meanwhile websocketd would have acquired a concept it currently does not have: a registry of live connections, with a lifetime, a memory cost, and its own failure modes. The alternative is not a hardship, because the thing that fans messages out is a thing you probably want to be able to restart independently of your web tier anyway. A message bus, a shared file, or a long-lived backend service that the per-connection scripts talk to: all three are ordinary, and all three keep working when you add a second host, which an in-process broadcast would not. Sharing state across connections works through the choice. Why not a process pool A related idea, and one that got further: rather than forking a new process per connection, keep a pool of already-started processes and hand each new connection an idle one. Forking is the expensive part of the process model , and pooling is the standard way to amortise it. It was implemented in 2014 and then explicitly removed. Pooling requires the pooled thing to be reusable, and a program that reads stdin until end of input is not. To hand a used process to a new client you must be able to reset it: clear its accumulated state, rewind whatever it read, discard whatever it half-wrote, and be sure the previous connection's data cannot leak into the next one. None of that is possible from outside the process. The only way to achieve it is for the program itself to know it is being pooled and to reset on command, which means a new obligation in the contract, and a leak of one connection's data into another if any program gets that obligation wrong. That trade is a bad one. Fork cost is real but bounded, and it is paid in exchange for the crash isolation and the zero-effort statefulness that make the model worth having. A pool would have traded a guarantee for a performance improvement, on a tool whose whole appeal is the guarantee. Why no pty websocketd hands your program three pipes. It never allocates a pseudo-terminal, a pty, which is the kernel device that makes a program believe a real terminal is attached. A pty would fix one real problem. Runtimes check whether stdout is a terminal and switch to block buffering when it is not, which is the cause of nearly every "my script sends nothing" report; see output buffering . Under a pty they would stay line-buffered and the problem would evaporate. But a pty is not a buffering fix with no other effects. It is a terminal, and a terminal brings a large surface with it: line discipline, echo, canonical versus raw modes, window size and the SIGWINCH that reports changes to it, job control, signal generation from control characters, and a stream of escape sequences mixed into the output that a WebSocket client would then have to interpret. A wrapped program under a pty starts emitting cursor movement and colour codes. websocketd would go from a pipe with a framing rule to a partial terminal emulator, and the browser side would need a terminal emulator to match. That is a different product, and good ones already exist. There is also a plainer reason: pty allocation is a Unix concept, and websocketd runs on Windows. The permanent consequence is that programs requiring a real terminal do not work through websocketd. vim, less, screen, watch and docker run -it were each reported independently, and they are all the same limitation. Non-newline-terminated interactive input, the keystroke-at-a-time model those programs want, is not supported and is not planned. Message framing explains why --binary does not change this, which is the usual next question. Why no built-in authentication Authentication is the piece of a system most tightly coupled to the organisation it lives in. A cookie from an existing session store, a bearer token from an identity provider, mutual TLS, an internal service mesh, HTTP basic auth behind a VPN: these are not variations on one feature, they are entirely different systems, and the right one is determined by infrastructure that websocketd cannot see. Choosing one would serve a narrow slice of deployments. It would also change what websocketd is. A tool that authenticates must store or verify credentials, which brings key management, rotation, timing-safe comparison, lockout policy, and a much less forgiving relationship with its own bugs. websocketd would become a security product that happens to pipe stdin, instead of a program that pipes stdin and delegates security to things built for it. The delegation is the point, and it is not a workaround: a reverse proxy in front, or a token check inside your own script, both of which are covered in adding authentication and in the security model . Mutual TLS via --sslca is the one exception, and it is there because it happens at the transport layer where websocketd already sits, without requiring websocketd to hold a credential store. Why no compression The WebSocket protocol has a compression extension, permessage-deflate, and websocketd does not implement it. Every message goes over the wire uncompressed. The traffic websocketd carries is usually small messages sent frequently, which is the case where per-message compression pays worst: the dictionary never warms up and the CPU cost is not repaid. Where compression does pay, the payloads are large and usually already compressed at the application layer, or the deployment already has a reverse proxy in the path that can do it. Adding the extension would mean negotiation state, a per-connection compression context with its own memory cost, and a well-known class of memory-exhaustion concerns, in exchange for a saving that the deployments most likely to want it can already obtain elsewhere. Why multiple listen sockets look the way they do websocketd can listen on several addresses, and the way you ask for it is by repeating --address. That flat, repeatable flag is the second design. The first was a more elaborate multi-socket implementation, and it did not work properly. It was replaced in 2014 by the repeated-flag model, which has the advantage of being obvious: each occurrence adds one listener, there is no configuration syntax to learn, and there is no state shared between listeners to get wrong. It is a good illustration of the general pattern on this page, which is that the simple version survived and the clever version did not. Why version numbers are typed by hand websocketd's version string is set manually in the source. It is not derived from the git tag, the commit, or the build date. This is the second time that decision was made. Automatic per-build version numbers were introduced and then reverted, on the grounds that a version number should mean something to a person deciding whether to upgrade, and a number that increments on every build does not. It marks a release, which is a deliberate act, not a compilation, which is not. The practical consequence is that the version you see from --version identifies a release rather than a build. If you need to know exactly which build you are running, the artefact you downloaded is what identifies it. What changed between releases is in the changelog . Next The process model is the decision the rest of these follow from. The security model covers the posture the no-authentication decision produces. The FAQ has the short forms of these answers. ======================================================================== FAQ https://websocketd.com/docs/faq/ ======================================================================== Ordered by how often people hit them. Every answer here is a pointer to the page that covers the subject. Why doesn't my script send output in real time? Your language's runtime is holding the output in a buffer, because stdout is a pipe rather than a terminal. The fix is one flag or one line, and it is on your language's page: Python , Ruby , PHP , C , Node.js . How do I share or broadcast data across connections? Not through websocketd: each connection gets its own process, and those processes share nothing. Put the shared part outside them, as described in share state across connections . Does this support authentication? No, websocketd has no built-in authentication and sets AUTH_TYPE and REMOTE_USER empty for every request. Authenticate in front of it or inside your script, both covered in add authentication . How do I pass URL or query-string data to my script? The query string arrives in your script's environment as QUERY_STRING, along with the rest of the CGI variables, with no flag needed. See pass data into your script , which also untangles this from --passenv. Why did my connection drop after a few minutes? --pingms defaults to 0, meaning websocketd sends no keepalive pings, so an idle connection can be dropped by a proxy, a load balancer, or the operating system without anything noticing. Set it to a non-zero value; the CLI flag reference has the details. Is this production-ready? Isn't a process per connection wasteful? It is a deliberate trade: a process per connection costs more than a thread, and in return connections are completely isolated and your backend can be written in anything. The process model sets out what that buys and where it stops making sense. How do I run a Windows .bat, .cmd, or .ps1 file? Windows ignores the #! line at the top of a script, so name the interpreter yourself and pass the script to it as an argument. See Windows scripts . How do I keep a process running, or run it exactly once? websocketd starts a fresh process for every connection and has no mode that changes this, so the run-once behaviour goes in a small wrapper around your program. See run a program once, or keep it running . Can I run this behind nginx or Apache? Yes, provided the proxy is configured to pass the WebSocket upgrade through rather than terminating it. Working configurations are on the nginx , Apache , and HAProxy pages. Why can't I use screen, watch, or docker run -it? websocketd gives your program plain pipes and never a pty, so anything that checks for a terminal before behaving interactively will not work through it. This is permanent, and the reasoning is in design decisions . ======================================================================== Changelog https://websocketd.com/docs/changelog/ ======================================================================== Every release, newest first, with the user-visible changes in each. Read the top entry before upgrading: anything that requires you to act before you upgrade is called out there as a breaking change.