<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Jikwang Moon</title>
    <description>The latest articles on DEV Community by Jikwang Moon (@moonjikwang).</description>
    <link>https://dev.to/moonjikwang</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4229489%2Fbf329523-ff2d-4eb4-96f0-9ea5dffc1f83.png</url>
      <title>DEV Community: Jikwang Moon</title>
      <link>https://dev.to/moonjikwang</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/moonjikwang"/>
    <language>en</language>
    <item>
      <title>How I built a fair waiting room with OpenResty and one Valkey script</title>
      <dc:creator>Jikwang Moon</dc:creator>
      <pubDate>Fri, 06 Nov 2026 02:43:07 +0000</pubDate>
      <link>https://dev.to/moonjikwang/how-i-built-a-fair-waiting-room-with-openresty-and-one-valkey-script-1o2l</link>
      <guid>https://dev.to/moonjikwang/how-i-built-a-fair-waiting-room-with-openresty-and-one-valkey-script-1o2l</guid>
      <description>&lt;p&gt;A waiting room (or "virtual queue") is what you see when a ticket sale opens: &lt;em&gt;"You are number 1,342 in line."&lt;/em&gt; Behind it is a simple promise: &lt;strong&gt;let in only as many people as the site can handle, and let the rest in, in the order they arrived.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I built one into &lt;a href="https://github.com/corvus-gateway/corvus" rel="noopener noreferrer"&gt;Corvus&lt;/a&gt;, a self-hosted gateway on OpenResty. This post walks through how it works, the parts that were harder than they looked, and the trade-offs I made.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "working" means
&lt;/h2&gt;

&lt;p&gt;Before writing code I wrote down what the queue must guarantee:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Capacity&lt;/strong&gt;: never more than N visitors inside at once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Order&lt;/strong&gt;: first come, first served. A new visitor must not skip people who are already waiting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No races&lt;/strong&gt;: several nginx workers (or several gateways) must never hand out the same free slot twice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cheap for people inside&lt;/strong&gt;: visitors who are already admitted should not cost a database round trip on every request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No trust in the browser&lt;/strong&gt;: closing a tab sends nothing reliable, so slots must free themselves.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Survive bad days&lt;/strong&gt;: a Valkey outage or a bot flood must not take the site down or empty the queue.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The data model: five Valkey keys per room
&lt;/h2&gt;

&lt;p&gt;All the state of one room lives in &lt;a href="https://valkey.io" rel="noopener noreferrer"&gt;Valkey&lt;/a&gt;, the open-source fork of Redis that ships inside the Corvus container. It speaks the same protocol and runs the same Lua scripts, so everything below works unchanged on Redis too, and Corvus can point at an external Redis or Valkey when several gateways share one queue.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Key&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Content&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;active&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;sorted set&lt;/td&gt;
&lt;td&gt;admitted sessions, score = expiry time (ms)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;queue&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;sorted set&lt;/td&gt;
&lt;td&gt;waiting sessions, score = ticket number&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;seen&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;sorted set&lt;/td&gt;
&lt;td&gt;waiting sessions, score = last time they checked in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;seq&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;string&lt;/td&gt;
&lt;td&gt;ticket counter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;rate&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;hash&lt;/td&gt;
&lt;td&gt;admissions in the current second&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The key names share a hash tag, &lt;code&gt;corvus:wr:{room}:active&lt;/code&gt;, &lt;code&gt;corvus:wr:{room}:queue&lt;/code&gt;…, so in a Valkey (or Redis) Cluster they land in the same slot and one script can touch all of them.&lt;/p&gt;

&lt;p&gt;Sorted sets do a lot of the work here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Free expired slots" is one &lt;code&gt;ZREMRANGEBYSCORE active -inf now&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;"Who is next" is &lt;code&gt;ZPOPMIN queue&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;"What is my position" is &lt;code&gt;ZRANK queue sid&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  One Lua script, run atomically inside Valkey
&lt;/h2&gt;

&lt;p&gt;Requirement 3 is the classic trap. "Read the number of active sessions, and if there is room, add one" is a race if two workers do it at the same time. Valkey runs a Lua script (&lt;code&gt;EVALSHA&lt;/code&gt;) atomically, so the whole state machine is a single script and nothing else can run in between.&lt;/p&gt;

&lt;p&gt;The script supports a handful of operations: &lt;code&gt;enter&lt;/code&gt;, &lt;code&gt;poll&lt;/code&gt;, &lt;code&gt;heartbeat&lt;/code&gt;, &lt;code&gt;release&lt;/code&gt;, &lt;code&gt;stats&lt;/code&gt;. Every call starts the same way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight lua"&gt;&lt;code&gt;&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'TIME'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;tonumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;math.floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;tonumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;-- Expired sessions free their slot (TTL/heartbeat based release; browser close is never trusted).&lt;/span&gt;
&lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ZREMRANGEBYSCORE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;K_ACTIVE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'-inf'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The time comes from Valkey's &lt;code&gt;TIME&lt;/code&gt;, not from the gateway. With several gateways there is exactly one clock, so "expired" means the same thing everywhere.&lt;/p&gt;

&lt;h3&gt;
  
  
  No queue jumping
&lt;/h3&gt;

&lt;p&gt;A new visitor gets in directly only if &lt;strong&gt;nobody is waiting&lt;/strong&gt; and there is a free slot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight lua"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- New arrival: admit directly only when nobody is waiting (no queue jumping).&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ZCARD'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;K_QUEUE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;budget&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
  &lt;span class="n"&gt;admit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;active_ttl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;finish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ACTIVE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;seq&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'INCR'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;K_SEQ&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ZADD'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;K_QUEUE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ZADD'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;K_SEEN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;finish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;WAITING&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without the &lt;code&gt;ZCARD == 0&lt;/code&gt; check, a visitor who happens to arrive the moment a slot frees up would take it, ahead of someone who has been waiting ten minutes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Promotion: who gets the free slot
&lt;/h3&gt;

&lt;p&gt;Before answering, the script moves people from the head of the line into free slots:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight lua"&gt;&lt;code&gt;&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;promote&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;scanned&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
  &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;scanned&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;promote_max&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;budget&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;head&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ZPOPMIN'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;K_QUEUE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;#&lt;/span&gt;&lt;span class="n"&gt;head&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt;
    &lt;span class="n"&gt;scanned&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scanned&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;head&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;tonumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ZSCORE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;K_SEEN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ZREM'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;K_SEEN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;queue_ttl&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
      &lt;span class="n"&gt;admit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;grant_ttl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Abandoned waiters are skipped.&lt;/strong&gt; Someone who closed the tab an hour ago is still in the sorted set. If they stopped checking in for longer than &lt;code&gt;queue_ttl&lt;/code&gt;, they are dropped instead of being handed a slot nobody will use.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A promoted visitor gets a short slot first.&lt;/strong&gt; The slot lasts only &lt;code&gt;grant_ttl&lt;/code&gt; (30 s by default). If they don't come back to claim it, it frees up quickly. When they do come back, it is extended to the full &lt;code&gt;active_ttl&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;budget()&lt;/code&gt; is the smaller of "free slots" and "admissions left this second", so the optional admission rate ("let in at most 50 people per second") is enforced in the same atomic step.&lt;/p&gt;

&lt;h3&gt;
  
  
  Slots free themselves
&lt;/h3&gt;

&lt;p&gt;An admitted session's score in &lt;code&gt;active&lt;/code&gt; is its expiry time. Every request from that visitor pushes it forward (&lt;code&gt;ZADD ... XX now + active_ttl&lt;/code&gt;). When they stop making requests, the score falls behind &lt;code&gt;now&lt;/code&gt; and the next script call removes it. There is also a &lt;code&gt;leave&lt;/code&gt; endpoint for the "Leave queue" button, but nothing depends on it. You can't count on a closing browser to tell you anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping Valkey off the hot path
&lt;/h2&gt;

&lt;p&gt;If every request of every admitted visitor ran the script, Valkey would become the bottleneck for the whole site. So the gateway hands out a &lt;strong&gt;signed admission token&lt;/strong&gt; in a cookie:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;v2.&amp;lt;key id&amp;gt;.a.&amp;lt;room&amp;gt;.&amp;lt;session id&amp;gt;.&amp;lt;issued at&amp;gt;.&amp;lt;expires&amp;gt;.&amp;lt;HMAC-SHA256&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On each request, OpenResty checks it like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight lua"&gt;&lt;code&gt;&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;tok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;admission_from_cookie&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;room&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tok&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;ngx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;iat&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;room&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;refresh&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="c1"&gt;-- fast path: no Redis&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Token younger than &lt;code&gt;refresh&lt;/code&gt; (15 s):&lt;/strong&gt; the HMAC check is all that happens. No network call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Older but valid:&lt;/strong&gt; one &lt;code&gt;heartbeat&lt;/code&gt; call extends the slot in Valkey, and a fresh token is issued.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No or invalid token:&lt;/strong&gt; an &lt;code&gt;enter&lt;/code&gt; call admits or queues the visitor.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So an admitted visitor costs Valkey at most one tiny call every 15 seconds, however many requests their browser makes. The trade-off is a revocation lag: an "emergency release" or a capacity change can take up to &lt;code&gt;refresh&lt;/code&gt; seconds to reach everyone. For a waiting room, that's fine.&lt;/p&gt;

&lt;p&gt;The key id in the token allows rotating the signing secret: the gateway accepts tokens signed with the current and the previous secret and signs new ones with the current one.&lt;/p&gt;

&lt;p&gt;In the project's benchmark (Corvus pinned to 2 vCPU, tiny responses), a host behind an access queue served about &lt;strong&gt;58k req/s&lt;/strong&gt; to admitted visitors, compared to about &lt;strong&gt;75k req/s&lt;/strong&gt; for a plain host. On that path Valkey is barely involved; the cost is the extra Lua work per request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Surviving floods
&lt;/h2&gt;

&lt;p&gt;A queue is a juicy target. If every request without a cookie created a queue entry, a simple &lt;code&gt;curl&lt;/code&gt; loop would fill the line with millions of fake visitors.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1: a cookie check that creates no state.&lt;/strong&gt; A first visit doesn't touch Valkey at all. The visitor gets a signed queue cookie and a redirect to the same URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight lua"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;sid&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
  &lt;span class="c1"&gt;-- Cookie check: a first visit creates no server-side state. The client gets a&lt;/span&gt;
  &lt;span class="c1"&gt;-- signed queue cookie and must come back with it before Redis is touched, so&lt;/span&gt;
  &lt;span class="c1"&gt;-- cookie-less floods can neither fill the queue nor take admission slots.&lt;/span&gt;
  &lt;span class="n"&gt;issue_queue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;room&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new_sid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ngx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ngx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request_uri&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;302&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A bot that ignores cookies loops on this redirect forever and never reaches Valkey.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2: limits for clients that do keep cookies.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;At most 60 new queue entries per IP per minute (configurable). This is kept in an nginx shared dict, so it costs no Valkey work.&lt;/li&gt;
&lt;li&gt;At most 3 status checks per second per session.&lt;/li&gt;
&lt;li&gt;A maximum queue length (100,000 by default). Beyond it, visitors get a "come back later" page instead of growing Valkey forever.&lt;/li&gt;
&lt;li&gt;If Valkey reports it is out of memory, visitors get the same "busy" answer. A full Valkey must not be treated as "Valkey is down, let everyone in".&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When Valkey is actually down
&lt;/h2&gt;

&lt;p&gt;Each room has a policy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;fail-open&lt;/strong&gt; lets everyone in and favours availability.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;fail-closed&lt;/strong&gt; shows a "try again" page and protects capacity.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Either way, &lt;strong&gt;visitors who already hold a valid admission token always get through&lt;/strong&gt;, because their check doesn't need Valkey.&lt;/p&gt;

&lt;p&gt;To avoid every request waiting for a connection timeout during an outage, there is a small circuit breaker. After one transport failure, all nginx workers skip Valkey for 3 seconds. The flag lives in a shared dict, so one worker's failure protects all of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The waiting page and the API
&lt;/h2&gt;

&lt;p&gt;Browsers get a waiting page that polls &lt;code&gt;/__corvus/wr/&amp;lt;room&amp;gt;/status&lt;/code&gt;. The poll interval grows with your position: 3 s near the front, up to 30 s far back. So 50,000 waiting people don't hammer the gateway every second. On errors the page backs off exponentially, up to 60 s. Several tabs of the same browser share one session, so opening ten tabs doesn't give you ten places in line.&lt;/p&gt;

&lt;p&gt;Requests that can't show a page (&lt;code&gt;fetch&lt;/code&gt;, XHR, mobile apps) get &lt;strong&gt;&lt;code&gt;429 Too Many Requests&lt;/code&gt;&lt;/strong&gt; with a &lt;code&gt;Retry-After&lt;/code&gt; header and a small JSON body (&lt;code&gt;rank&lt;/code&gt;, &lt;code&gt;waiting&lt;/code&gt;, &lt;code&gt;retry_after&lt;/code&gt;). I deliberately avoided &lt;code&gt;503&lt;/code&gt;. The site isn't broken, and a waiting room shouldn't light up your 5xx alerts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trade-offs I accepted
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Capacity counts sessions, not requests.&lt;/strong&gt; "500 visitors" means 500 browsers inside, whatever they do there. That is what people expect from a waiting room, but it isn't a rate limiter. (Corvus has a separate one.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-site API calls stay queued.&lt;/strong&gt; The cookie is &lt;code&gt;SameSite=Lax&lt;/code&gt;, so a web app on another site calling a queued API won't send it. Only put the queue on what people open in a browser.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shared IPs and the per-IP limit.&lt;/strong&gt; A big office behind one NAT can hit the 60-entries-per-minute limit. It's configurable for that reason.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Optional network binding.&lt;/strong&gt; Admission tokens can be bound to the visitor's /24 (IPv6 /48), so a token can't be passed around. Visitors who switch from Wi-Fi to mobile data then have to queue again, which is why it's off by default.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;The core idea fits in one sentence: &lt;strong&gt;put the whole state machine in one atomic Valkey script, and use signed tokens so admitted visitors almost never need it.&lt;/strong&gt; Most of the remaining work went into the unhappy paths: abandoned tabs, floods, a full Valkey, a dead Valkey.&lt;/p&gt;

&lt;p&gt;The full code is on GitHub:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the Valkey script: &lt;a href="https://github.com/corvus-gateway/corvus/blob/main/gateway/redis/waitroom.lua" rel="noopener noreferrer"&gt;&lt;code&gt;gateway/redis/waitroom.lua&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;the OpenResty side: &lt;a href="https://github.com/corvus-gateway/corvus/blob/main/gateway/lua/corvus/waitroom.lua" rel="noopener noreferrer"&gt;&lt;code&gt;gateway/lua/corvus/waitroom.lua&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In Corvus you turn it on from the console in a couple of clicks: create an access queue, then attach it to a proxy host.&lt;/p&gt;

&lt;p&gt;Questions, or a case I didn't think of? I'd love to hear it in the comments.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Corvus is built with heavy help from an AI coding assistant, and this post was drafted with one too. The design decisions, verification and testing are my own.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>valkey</category>
      <category>redis</category>
      <category>nginx</category>
      <category>lua</category>
    </item>
    <item>
      <title>I liked Nginx Proxy Manager, but I wanted a WAF in the same place</title>
      <dc:creator>Jikwang Moon</dc:creator>
      <pubDate>Fri, 06 Nov 2026 02:06:51 +0000</pubDate>
      <link>https://dev.to/moonjikwang/i-liked-nginx-proxy-manager-but-i-wanted-a-waf-in-the-same-place-6j0</link>
      <guid>https://dev.to/moonjikwang/i-liked-nginx-proxy-manager-but-i-wanted-a-waf-in-the-same-place-6j0</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft6sw91nsrm1el0le9v7q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft6sw91nsrm1el0le9v7q.png" alt=" " width="800" height="500"&gt;&lt;/a&gt;&lt;br&gt;
If you self-host anything, there is a good chance it sits behind Nginx Proxy Manager (NPM). Add a domain, pick a container, click Let's Encrypt, done. I used it for exactly that reason.&lt;/p&gt;

&lt;p&gt;Then you open the logs of anything exposed to the internet and see this, every day:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /.env
GET /wp-config.php
GET /../../etc/passwd
GET /?id=1 UNION SELECT password FROM users
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You don't run WordPress and you don't have a &lt;code&gt;.env&lt;/code&gt; in your web root, but the scanners don't care. At some point I wanted three things: &lt;strong&gt;see what is actually hitting my services, block the obvious junk, and keep some control when traffic spikes.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The usual answer
&lt;/h2&gt;

&lt;p&gt;The common setups are:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;NPM + CrowdSec&lt;/strong&gt; (or fail2ban) to ban bad IPs from the logs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A WAF container&lt;/strong&gt; in front of or behind NPM: SafeLine, BunkerWeb, ModSecurity…&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;All good tools. But you end up with more containers, more config screens, and when a request is blocked you jump between three places to find out why. I wanted the proxy and the protection &lt;strong&gt;in one simple console, the way NPM feels.&lt;/strong&gt; So I built Corvus.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Corvus is
&lt;/h2&gt;

&lt;p&gt;A self-hosted gateway that works like NPM and adds a layer that watches, blocks and controls traffic. It is not an NPM fork; it is written from scratch on OpenResty, Go and React. Apache-2.0, no paid tier.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Proxy hosts, Let's Encrypt with auto-renewal (DNS challenge for wildcards), redirections, TCP/UDP streams&lt;/li&gt;
&lt;li&gt;Known attack patterns (SQL injection, path traversal, scanners…) blocked as soon as you add a host&lt;/li&gt;
&lt;li&gt;Automatic bans for IPs that keep trying&lt;/li&gt;
&lt;li&gt;Security rules on IP, country, path, header and User-Agent, a browser challenge, and an automatic "under attack" mode&lt;/li&gt;
&lt;li&gt;An access queue (waiting room) that lets people in up to a capacity during a rush&lt;/li&gt;
&lt;li&gt;A live traffic monitor and alerts to Slack, Discord, Teams, Telegram and others&lt;/li&gt;
&lt;li&gt;Import from NPM, and every config change is versioned with rollback&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;corvus&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/corvus-gateway/gateway:latest&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;80:80'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;443:443'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;9443:9443'&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./data:/data&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
docker compose logs corvus | &lt;span class="nb"&gt;grep &lt;/span&gt;setup_token
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no default password. The first start prints a one-time &lt;strong&gt;setup token&lt;/strong&gt; so that only the server owner can create the admin account. Open &lt;code&gt;https://&amp;lt;server&amp;gt;:9443&lt;/code&gt; (self-signed certificate at first, so expect a browser warning), enter the token and create your account. If you'd rather pick the token yourself, set &lt;code&gt;CORVUS_SETUP_TOKEN&lt;/code&gt; (12+ characters).&lt;/p&gt;

&lt;p&gt;On TrueNAS SCALE, Corvus is also in the Community apps catalog: search for "Corvus" under Apps.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you get without writing a single rule
&lt;/h2&gt;

&lt;p&gt;Go to &lt;strong&gt;Proxy hosts → Add&lt;/strong&gt;, enter the domain and the target (say &lt;code&gt;shop-web:80&lt;/code&gt;), choose Let's Encrypt, save. Same as NPM so far.&lt;/p&gt;

&lt;p&gt;The difference is that protection is already on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Exploit path blocking&lt;/strong&gt; is enabled by default. All four requests from the top of this post are stopped before they reach your app. I checked them against a test stack: &lt;code&gt;403&lt;/code&gt;, &lt;code&gt;403&lt;/code&gt;, &lt;code&gt;400&lt;/code&gt;, &lt;code&gt;403&lt;/code&gt;, while normal requests return &lt;code&gt;200&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automatic bans&lt;/strong&gt; are on too: an IP with more than 20 detections in 10 minutes is banned for 15 minutes, and the ban doubles on every repeat (up to 7 days by default).&lt;/li&gt;
&lt;li&gt;Every request lands in the &lt;strong&gt;traffic monitor&lt;/strong&gt;, so you can see per host which paths were hit and what was blocked.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foz85d5dztg2rkx1f1502.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foz85d5dztg2rkx1f1502.png" alt="Traffic monitor" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  When you want more
&lt;/h2&gt;

&lt;p&gt;All optional:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Security rules&lt;/strong&gt;, e.g. "&lt;code&gt;/admin&lt;/code&gt; only from my office IP" or "block these countries". Tip: start a rule in &lt;strong&gt;Log&lt;/strong&gt; mode, see what it would catch, then switch to &lt;strong&gt;Block&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Browser challenge&lt;/strong&gt;: a short proof-of-work in the browser, no third-party service. Useful when bots pile in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Under attack mode&lt;/strong&gt;: when requests per second or the block ratio pass a threshold, every visitor gets the challenge automatically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Access queue&lt;/strong&gt;: for a launch or a limited drop, let in only as many people as your server can handle and queue the rest in order.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1tslr8iu2phsg9po6hik.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1tslr8iu2phsg9po6hik.png" alt="Security rules" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Coming from NPM
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Import from NPM&lt;/strong&gt; brings over proxy hosts, redirections, streams, certificates and access lists. It only &lt;strong&gt;reads&lt;/strong&gt; NPM's files and database, so if you don't like Corvus you can simply go back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;It is young (1.1.x). Bugs reported as issues get fixed quickly.&lt;/li&gt;
&lt;li&gt;Auto-bans work per IP, so people behind the same company or carrier NAT can be banned together. Put networks you know into the exception list.&lt;/li&gt;
&lt;li&gt;No single gateway can stop a DDoS that saturates your uplink. For that, put a CDN such as Cloudflare in front; Corvus still sees the real visitor IPs behind it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Feedback welcome
&lt;/h2&gt;

&lt;p&gt;I built Corvus with heavy help from an AI coding assistant; the design, verification and testing I do myself, change by change. If you try it, I'd love to hear what was annoying during install or use, and what you'd need.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/corvus-gateway/corvus" rel="noopener noreferrer"&gt;https://github.com/corvus-gateway/corvus&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Docs: &lt;a href="https://corvus-gateway.github.io/corvus/" rel="noopener noreferrer"&gt;https://corvus-gateway.github.io/corvus/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>docker</category>
      <category>security</category>
      <category>nginx</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
