<?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: Sourav Sarkar</title>
    <description>The latest articles on DEV Community by Sourav Sarkar (@sourav_sarkar_78787d235fa).</description>
    <link>https://dev.to/sourav_sarkar_78787d235fa</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%2F3630403%2Fa99795b1-d6e9-4ff1-8752-b651ca419fbd.png</url>
      <title>DEV Community: Sourav Sarkar</title>
      <link>https://dev.to/sourav_sarkar_78787d235fa</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sourav_sarkar_78787d235fa"/>
    <language>en</language>
    <item>
      <title>Killing the Shared Proxy: Direct Routing to Per-Customer Containers on Docker Swarm</title>
      <dc:creator>Sourav Sarkar</dc:creator>
      <pubDate>Sun, 12 Jul 2026 17:04:41 +0000</pubDate>
      <link>https://dev.to/sourav_sarkar_78787d235fa/killing-the-shared-proxy-direct-routing-to-per-customer-containers-on-docker-swarm-2h79</link>
      <guid>https://dev.to/sourav_sarkar_78787d235fa/killing-the-shared-proxy-direct-routing-to-per-customer-containers-on-docker-swarm-2h79</guid>
      <description>&lt;h3&gt;
  
  
  We already ran one container per customer — but every request was relayed through a single shared one. Here's how we cut it out, and everything that turned out to be attached to it.
&lt;/h3&gt;

&lt;p&gt;Our platform runs one container per customer on Docker Swarm. Each customer gets an isolated process with their own config, their own secrets, their own AI agent. (In the jargon this is a &lt;em&gt;tenant&lt;/em&gt; — but they're customers, so I'll call them that.)&lt;/p&gt;

&lt;p&gt;Clean isolation. And then every single request went through one shared container first.&lt;/p&gt;

&lt;p&gt;This is the story of getting that container out of the path.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the problem
&lt;/h2&gt;

&lt;p&gt;The architecture was a shared "control plane" container plus N per-customer containers — and &lt;em&gt;everything&lt;/em&gt; entered through the shared one:&lt;/p&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%2Fu1n0x7yiv3e9u2x5f17a.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%2Fu1n0x7yiv3e9u2x5f17a.png" alt=" " width="800" height="615"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The shared container did the sensible thing: authenticate the JWT, check the subscription, then reverse-proxy the request to the right customer container over the overlay network.&lt;/p&gt;

&lt;p&gt;It worked. It also meant &lt;strong&gt;every customer's every request&lt;/strong&gt; — every chat load, every analytics query, every dashboard poll — was buffered and relayed through a single Node process. That process did a JWT verify, two database reads, and a full request/response relay before the customer's own container even saw the request.&lt;/p&gt;

&lt;p&gt;One container. All customers. Both a bottleneck and a single point of failure, for traffic that had a perfectly good home elsewhere.&lt;/p&gt;

&lt;p&gt;The customer containers were already doing the actual work — the AI, the message handling, the data. They just couldn't be reached without going through the middleman.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why you can't just point nginx at the container
&lt;/h2&gt;

&lt;p&gt;The obvious fix: have the edge route straight to &lt;code&gt;customer-{id}&lt;/code&gt; and skip the relay.&lt;/p&gt;

&lt;p&gt;Except the host nginx &lt;strong&gt;cannot resolve&lt;/strong&gt; &lt;code&gt;customer-{id}&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Customer containers live on a Docker &lt;strong&gt;overlay network&lt;/strong&gt;. Their DNS names are served by Docker's embedded resolver at &lt;code&gt;127.0.0.11&lt;/code&gt;, which only exists &lt;em&gt;inside&lt;/em&gt; containers attached to that network. The host nginx is a system process — it isn't on the overlay, so those names may as well not exist.&lt;/p&gt;

&lt;p&gt;That leaves two options:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Publish a host port per customer.&lt;/strong&gt; &lt;code&gt;customer-a → :4001&lt;/code&gt;, &lt;code&gt;customer-b → :4002&lt;/code&gt;, and nginx maps them. This breaks the moment you have more than one node — Swarm can schedule a container anywhere, and it moves on redeploy — so nginx would need to know which node holds which customer, and update on every reschedule. It also needs config regeneration on every signup and offboard, and it exhausts ports. Dead end, and worse, a dead end you'd have to rip out later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Put an nginx &lt;em&gt;inside&lt;/em&gt; the overlay network.&lt;/strong&gt; It can resolve Docker DNS natively. The host nginx terminates TLS and forwards to it; it does the lookup and the routing.&lt;/p&gt;

&lt;p&gt;We went with the second one. It works identically on one node and on many, which matters — the whole point was to avoid building something we'd have to redo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deterministic routing: the subdomain &lt;em&gt;is&lt;/em&gt; the address
&lt;/h2&gt;

&lt;p&gt;Give each customer a subdomain whose first label is their container's short ID:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{shortId}.api.example.com  →  customer-{shortId} container
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That single decision removes an entire category of work. There's &lt;strong&gt;no service discovery, no per-customer config file, and no reload when someone signs up or leaves&lt;/strong&gt; — the subdomain label &lt;em&gt;is&lt;/em&gt; the container name. The edge doesn't look anything up. It does string interpolation.&lt;/p&gt;

&lt;p&gt;The result splits the world in two — a control plane that genuinely needs to be shared, and everything else going straight to the container that owns it:&lt;/p&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%2F3fhob37kdmqzjl866ct9.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%2F3fhob37kdmqzjl866ct9.png" alt=" " width="800" height="607"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The host nginx can't resolve the container names, so it terminates TLS and hands off to the edge — which lives on the overlay network and &lt;em&gt;can&lt;/em&gt;. That's the whole trick.&lt;/p&gt;

&lt;p&gt;The whole edge config is about forty lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Docker's embedded DNS. Not optional — see below.&lt;/span&gt;
&lt;span class="k"&gt;resolver&lt;/span&gt; &lt;span class="mf"&gt;127.0&lt;/span&gt;&lt;span class="s"&gt;.0.11&lt;/span&gt; &lt;span class="s"&gt;valid=10s&lt;/span&gt; &lt;span class="s"&gt;ipv6=off&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;4100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;"~^(?&amp;lt;shortid&amp;gt;[0-9a-f]&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="kn"&gt;8&lt;/span&gt;&lt;span class="err"&gt;}&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="s"&gt;..+&lt;/span&gt;$&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/api/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;set&lt;/span&gt; &lt;span class="nv"&gt;$upstream&lt;/span&gt; &lt;span class="s"&gt;"customer-&lt;/span&gt;$&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="kn"&gt;shortid&lt;/span&gt;&lt;span class="err"&gt;}&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://&lt;/span&gt;&lt;span class="nv"&gt;$upstream&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;4000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="c1"&gt;# ... standard proxy headers&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things in there are load-bearing and easy to get wrong:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;resolver&lt;/code&gt; is required.&lt;/strong&gt; Without it, nginx resolves upstream names &lt;em&gt;once at config load&lt;/em&gt; and caches the IP forever. Containers restart and get new IPs — you'd 502 until someone reloaded nginx.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The variable in &lt;code&gt;proxy_pass&lt;/code&gt; is what forces per-request resolution.&lt;/strong&gt; Write &lt;code&gt;proxy_pass http://customer-abc:4000&lt;/code&gt; with a literal hostname and nginx resolves it at startup regardless of your resolver. The &lt;code&gt;set $upstream&lt;/code&gt; indirection is what makes it re-resolve. This is a long-standing nginx quirk, and it's the difference between "works" and "works until a container restarts."&lt;/p&gt;

&lt;p&gt;And it's multi-node ready for free: overlay DNS resolves cluster-wide, so when containers eventually spread across worker nodes, this config doesn't change at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The traffic moved. So did everything attached to it.
&lt;/h2&gt;

&lt;p&gt;This is the part that's easy to underestimate. The shared proxy wasn't &lt;em&gt;only&lt;/em&gt; forwarding requests — it was the place a bunch of other things happened to live. Take it out of the path and you find out what was quietly depending on it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Auth and gating
&lt;/h3&gt;

&lt;p&gt;The shared container authenticated the JWT and checked the subscription &lt;em&gt;before&lt;/em&gt; proxying. Remove the proxy and that gate is simply gone.&lt;/p&gt;

&lt;p&gt;So each customer container now does its own:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;JWT verification&lt;/strong&gt; — it already had the signing keys. Same image, same secret bootstrap; the code path was there, just skipped because "the proxy already did it."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subscription check&lt;/strong&gt; — cached, so it's a map lookup on the hot path, not a database read per request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Active-account check&lt;/strong&gt; — replacing the proxy's "is this account live?" gate. A paused customer's container might still be running for a moment during scale-down; it now refuses to serve.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The guard that only exists because containers are public now
&lt;/h3&gt;

&lt;p&gt;Here's the one that matters. Once containers are individually addressable from the internet, a request can arrive at the &lt;em&gt;wrong&lt;/em&gt; one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A container must refuse to serve any request that isn't for its own customer.&lt;/strong&gt; The URL carries an ID; the container knows its own. If they don't match — 404, and log it.&lt;/p&gt;

&lt;p&gt;Without that check, a perfectly valid token for customer A, pointed at customer B's subdomain, would be processed by B's container — using B's config, B's credentials, B's connections. The same check goes on the WebSocket handshake.&lt;/p&gt;

&lt;p&gt;That single guard is the entire reason public per-customer addressing is safe rather than reckless. If you do this, do that.&lt;/p&gt;

&lt;p&gt;Every gate the shared proxy used to run now runs on the container itself, in front of the route:&lt;/p&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%2Fjgekscdzlo7c4e933rx5.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%2Fjgekscdzlo7c4e933rx5.png" alt=" " width="800" height="1478"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;identity bound&lt;/code&gt; step is the new one. The rest existed already — they were just running somewhere else.&lt;/p&gt;

&lt;h3&gt;
  
  
  Inbound webhooks
&lt;/h3&gt;

&lt;p&gt;Third-party webhooks (in our case, inbound messages) were also landing on the shared container, which validated the signature and forwarded to the customer's container.&lt;/p&gt;

&lt;p&gt;Now the webhook URL each customer registers points at &lt;em&gt;their own&lt;/em&gt; subdomain. The message arrives directly, and their container validates the signature with their own secret. The shared container isn't involved in a single inbound message anymore.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one we almost missed: WebSockets
&lt;/h2&gt;

&lt;p&gt;We moved the HTTP traffic. Dashboards loaded straight from customer containers. The shared container's logs went quiet.&lt;/p&gt;

&lt;p&gt;Then we looked at what was actually left in them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;socket_connected
socket_connected
POST /api/internal/emit-socket   200 5ms
POST /api/internal/emit-socket   200 3ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;strong&gt;socket hub was still there.&lt;/strong&gt; Every open browser tab, for every customer, held a live WebSocket to the shared container. And every message a customer's AI produced was making an internal HTTP call &lt;em&gt;back&lt;/em&gt; to the shared container so it could fan out to that browser:&lt;/p&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%2F5fb903q5nhaoz16c62i5.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%2F5fb903q5nhaoz16c62i5.png" alt=" " width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is &lt;em&gt;worse&lt;/em&gt; than the HTTP bottleneck we'd just removed, not better:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An HTTP request completes and frees its resources.&lt;/li&gt;
&lt;li&gt;A WebSocket &lt;strong&gt;sits there&lt;/strong&gt;. Memory and file descriptors grow with &lt;strong&gt;concurrent users&lt;/strong&gt;, not with request rate.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We had removed the bottleneck that scales with traffic and left in place the one that scales with &lt;em&gt;people logged in&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The fix was the same trick — the edge already routed &lt;code&gt;/socket.io/&lt;/code&gt;, so the browser just connects to &lt;code&gt;{shortId}.api.example.com&lt;/code&gt; instead. Each container now holds only its own customer's sockets and emits to them locally. No cross-container hop per message.&lt;/p&gt;

&lt;p&gt;One nice wrinkle: the internal forward channel didn't disappear. It &lt;strong&gt;flipped direction&lt;/strong&gt;. Billing and quota events are raised on the &lt;em&gt;shared&lt;/em&gt; container (scheduled jobs, subscription lifecycle) but need to reach a customer's browsers, which now live on the customer's container. So shared forwards &lt;em&gt;to&lt;/em&gt; the container instead of the container forwarding &lt;em&gt;to&lt;/em&gt; shared. Same channel, opposite arrow:&lt;/p&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%2Fdxqat7nf7491uo2fsr95.jpg" 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%2Fdxqat7nf7491uo2fsr95.jpg" alt=" " width="800" height="541"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The per-message path no longer touches the shared container at all. The only thing crossing containers now is the rare billing event — which is exactly the traffic that &lt;em&gt;should&lt;/em&gt; originate centrally.&lt;/p&gt;

&lt;h2&gt;
  
  
  What stays on the shared container — and why that's correct
&lt;/h2&gt;

&lt;p&gt;Not everything should move, and it's worth being explicit about why:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Login&lt;/strong&gt; — there is no customer container to route to yet. You don't know who you are.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Billing&lt;/strong&gt; — it's per-account, not per-customer-workspace. An account can own several.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Onboarding&lt;/strong&gt; — it &lt;em&gt;creates&lt;/em&gt; the container. It can't run on it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Admin&lt;/strong&gt; — cross-customer by definition.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the honest test for whether something belongs on a shared control plane: &lt;strong&gt;does it have customer context yet, and does it need exactly one customer?&lt;/strong&gt; If either answer is no, it stays shared. Everything else goes to the customer's own container.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it landed
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Browser
  ├─ auth, billing, onboarding, admin   → shared container
  │                                       (no customer context exists at login)
  └─ everything customer-scoped         → {shortId}.api.example.com
                                           → edge → customer container
                                              ├─ JWT verified here
                                              ├─ identity bound here
                                              ├─ subscription checked here
                                              └─ sockets held here

Inbound webhooks → {shortId}.api.example.com → customer container (directly)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The shared container is now out of the per-message path and out of the per-session path entirely. It handles login, billing, onboarding, admin — and nothing that happens while a customer is actually &lt;em&gt;using&lt;/em&gt; the product.&lt;/p&gt;

&lt;h2&gt;
  
  
  Small traps, for anyone doing this
&lt;/h2&gt;

&lt;p&gt;A few things cost us a deploy cycle each. In case they save you one:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;nginx reads &lt;code&gt;{8}&lt;/code&gt; in your regex as a config block.&lt;/strong&gt; The parser treats &lt;code&gt;{&lt;/code&gt; and &lt;code&gt;}&lt;/code&gt; as block delimiters, so a repetition count in an unquoted regex produces the baffling error &lt;code&gt;directive "server_name" is not terminated by ";"&lt;/code&gt;. Quote the whole regex and it's fine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Docker Swarm silently ignores the bind address in &lt;code&gt;127.0.0.1:4100:4100&lt;/code&gt;.&lt;/strong&gt; It publishes on &lt;code&gt;0.0.0.0&lt;/code&gt; and mentions this in a &lt;code&gt;WARN&lt;/code&gt; you will scroll straight past. Our firewall happened to block the port — but "we were fine because of a rule we didn't write for this purpose" isn't a security posture. Check the firewall; don't trust the YAML.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Swarm configs are immutable.&lt;/strong&gt; Editing the file and re-running &lt;code&gt;stack deploy&lt;/code&gt; changes nothing — the config object already exists under that name. Remove the service &lt;em&gt;and&lt;/em&gt; the config object, or version the config name so a rolling update actually rolls.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wildcard certs can't use HTTP-01.&lt;/strong&gt; &lt;code&gt;*.api.example.com&lt;/code&gt; requires a DNS-01 challenge. If your DNS provider has no certbot plugin, you fall back to &lt;code&gt;--manual&lt;/code&gt;, which does not auto-renew — and when it lapses, &lt;strong&gt;every customer subdomain goes TLS-invalid at the same moment.&lt;/strong&gt; If your registrar's DNS can't do API-driven ACME, delegate just that subdomain to one that can. (Also: &lt;code&gt;*.example.com&lt;/code&gt; does not match &lt;code&gt;example.com&lt;/code&gt;. Separate names, separate certs.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;The interesting part of this wasn't the nginx config. It was noticing how much had accumulated in the shared path &lt;em&gt;because it was there&lt;/em&gt; — auth, gating, webhook validation, the socket hub — none of which needed to be, and each of which was quietly making one container responsible for every customer.&lt;/p&gt;

&lt;p&gt;If you already isolate per customer, the routing layer is worth the effort to match. And when you go looking for what's still shared, check the long-lived connections. HTTP load scales with requests. WebSocket load scales with humans.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>nginx</category>
      <category>architecture</category>
      <category>devops</category>
    </item>
    <item>
      <title>Building Two SQL Dashboards: E-commerce Analytics + Student Performance Reporting</title>
      <dc:creator>Sourav Sarkar</dc:creator>
      <pubDate>Wed, 26 Nov 2025 10:57:49 +0000</pubDate>
      <link>https://dev.to/sourav_sarkar_78787d235fa/building-two-sql-dashboards-e-commerce-analytics-student-performance-reporting-5fh7</link>
      <guid>https://dev.to/sourav_sarkar_78787d235fa/building-two-sql-dashboards-e-commerce-analytics-student-performance-reporting-5fh7</guid>
      <description>&lt;p&gt;Over the past week, I worked on two hands-on SQL projects as part of my learning journey. Both projects strengthened my analytical skills and helped me understand how SQL powers real-world decision-making.&lt;/p&gt;

&lt;p&gt;This blog covers:&lt;/p&gt;

&lt;p&gt;E-commerce Analytics Dashboard (revenue, top products, customer behavior)&lt;/p&gt;

&lt;p&gt;Student Management Dashboard (averages, top scorers, subject difficulty)&lt;/p&gt;

&lt;p&gt;I’m sharing my queries, approach, and key findings so others learning SQL can see how business insights come together using joins, aggregations, window functions, and time-series logic.&lt;/p&gt;

&lt;p&gt;🛒 Project 1: E-commerce Analytics Dashboard (SQL)&lt;/p&gt;

&lt;p&gt;The goal was to understand customer behavior, product performance, and revenue trends using an e-commerce dataset consisting of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;customers&lt;/li&gt;
&lt;li&gt;products&lt;/li&gt;
&lt;li&gt;orders&lt;/li&gt;
&lt;li&gt;order_items&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Below are the insights I generated.&lt;/p&gt;

&lt;p&gt;📈 1. Monthly Revenue Trend (Last 24 Months)&lt;/p&gt;

&lt;p&gt;I used DATE_TRUNC() to group sales by month and track revenue over the last two years.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SELECT
    TO_CHAR(DATE_TRUNC('month', o.order_date), 'Month YYYY') AS month_label,
    DATE_TRUNC('month', o.order_date) AS month_start,
    SUM(oi.quantity * oi.unit_price) AS total_revenue,
    COUNT(*) AS total_orders
FROM orders o
JOIN order_items oi ON oi.order_id = o.order_id
WHERE o.order_date &amp;gt;= NOW() - INTERVAL '24 months'
GROUP BY month_start
ORDER BY month_start;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;🔍 What it tells us&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Month-over-month revenue changes&lt;/li&gt;
&lt;li&gt;Growth direction for the business&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;📸 Screenshot :&lt;br&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.amazonaws.com%2Fuploads%2Farticles%2Fm2ve9qbkio9cy7psrpgf.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.amazonaws.com%2Fuploads%2Farticles%2Fm2ve9qbkio9cy7psrpgf.png" alt=" " width="800" height="383"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;🔄 2. Customers Whose Orders Increased from 2024 → 2025&lt;/p&gt;

&lt;p&gt;This query compares order counts across years to identify fast-growing customers.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;select 
c.name,c.customer_id,
sum(case when extract(year from o.order_date) = 2025 then 1 else 0 end) as year_date_2025,
sum(case when extract(year from o.order_date) = 2024 then 1 else 0 end) as year_date_2024
from customers as c
join orders as o on o.customer_id  = c.customer_id
group by c.name,c.customer_id
having 
      sum(case when extract(year from o.order_date) = 2025 then 1 end)
    &amp;gt; sum(case when extract(year from o.order_date) = 2024 then 1 end)
   AND 
      sum(case when extract(year from o.order_date) = 2025 then 1 end) &amp;gt; 0
   AND
      sum(case when extract(year from o.order_date) = 2024 then 1 end) &amp;gt; 0;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;🔍 What it tells us&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which customers have become more active&lt;/li&gt;
&lt;li&gt;A simple year-over-year loyalty signal&lt;/li&gt;
&lt;li&gt;Helps identify customers worth targeting for retention campaigns&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;📸 Screenshot:&lt;br&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.amazonaws.com%2Fuploads%2Farticles%2F6a9i572axglwikobweuj.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.amazonaws.com%2Fuploads%2Farticles%2F6a9i572axglwikobweuj.png" alt=" " width="800" height="395"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;🏆 3. Top 3 Products in Each Category (Window Function)&lt;/p&gt;

&lt;p&gt;Using ROW_NUMBER() to rank items within each category:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;select * from (
    select 
        p.name as product_name, 
        p.category,
        p.product_id,
        sum(ot.quantity * ot.unit_price) as total_sales,
        row_number() over(
            partition by p.category
            order by sum(ot.quantity * ot.unit_price) desc
        ) as rn
    from products as p
    join order_items as ot on ot.product_id = p.product_id
    group by p.category,p.name,p.product_id
) x 
where rn &amp;lt;= 3
order by category, total_sales desc;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;🔍 What it tells us&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The top performers inside each category&lt;/li&gt;
&lt;li&gt;Helps identify products to promote or bundle&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;📸 Screenshot:&lt;br&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.amazonaws.com%2Fuploads%2Farticles%2Faznz1mmxor76t5wyrazg.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.amazonaws.com%2Fuploads%2Farticles%2Faznz1mmxor76t5wyrazg.png" alt=" " width="800" height="327"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;🛍️ 4. Top 10 Best-Selling Products (By Quantity)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;select 
products.product_id,
products.name as product_name,
sum(order_items.unit_price * order_items.quantity) as total_sale,
sum(order_items.quantity) as quantity
from products
join order_items on order_items.product_id = products.product_id
group by products.product_id
order by total_sale desc
limit 10;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;🔍 Key insight&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;1. These are your highest-demand products&lt;/li&gt;
&lt;li&gt;2. Useful for stock planning and reorder frequency&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;💰 5. Top 15 Revenue Generating Products&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;select 
products.product_id as product_id,
products.name as product_name,
sum(order_items.quantity) as total_units_sold,
round(avg(order_items.unit_price),2) as avg_unit_price,
sum(order_items.quantity * order_items.unit_price) as total_revenue
from products
join order_items on order_items.product_id = products.product_id
group by products.product_id
order by total_revenue desc
limit 15;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;🔍 Key insight&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;These products make the biggest contribution to revenue&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;📸 Screenshot :&lt;/p&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.amazonaws.com%2Fuploads%2Farticles%2Fv4909vjv0hbvxx213wl4.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.amazonaws.com%2Fuploads%2Farticles%2Fv4909vjv0hbvxx213wl4.png" alt=" " width="800" height="334"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;🧩 Other Insights (from my additional queries)&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Average Order Value per month → financial health indicator&lt;/li&gt;
&lt;li&gt;Category performance → which category earns the most&lt;/li&gt;
&lt;li&gt;One-time vs repeat customers → customer segmentation&lt;/li&gt;
&lt;li&gt;Top spenders → lifetime value leaderboard&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Together, these form a complete e-commerce analytics dashboard using SQL.&lt;/p&gt;

&lt;p&gt;🎓 Project 2: Student Management Dashboard (SQL)&lt;/p&gt;

&lt;p&gt;This project focused on analyzing academic performance using tables:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;students&lt;/li&gt;
&lt;li&gt;subjects&lt;/li&gt;
&lt;li&gt;scores&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The goal was to create insights similar to what a school dashboard would show.&lt;/p&gt;

&lt;p&gt;📊 1. Student Performance Summary&lt;/p&gt;

&lt;p&gt;Using AVG, MIN, MAX, and a CASE statement to create a “performance review”.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;select 
s.name ,
round(avg(sc.score),2) as avg_score,
min(sc.score) as min_score,
max(sc.score) as max_score,
case 
   when round(avg(sc.score),2) &amp;gt; 95 then 'Academically Blessed'
   when round(avg(sc.score),2) &amp;gt; 90 then 'Top Performer'
   when round(avg(sc.score),2) &amp;gt; 80 then 'Above Average'
   when round(avg(sc.score),2) &amp;gt; 70 then 'Average'
   when round(avg(sc.score),2) &amp;gt; 55 then 'Needs Improvement'
   else 'Failing'
end as performance_review
from scores as sc
join students as s on s.student_id = sc.student_id
group by s.name;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;🔍 What it tells us&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Each student's average&lt;/li&gt;
&lt;li&gt;Performance category&lt;/li&gt;
&lt;li&gt;Range of scores&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;📸 Screenshot:&lt;br&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.amazonaws.com%2Fuploads%2Farticles%2F015z13ixn5e1wo630zap.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.amazonaws.com%2Fuploads%2Farticles%2F015z13ixn5e1wo630zap.png" alt=" " width="800" height="365"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;🏅 2. Top Performers per Subject&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SELECT 
    sub.subject_name,
    s.name AS top_student,
    sc.score AS top_score
FROM scores sc
JOIN subjects sub ON sub.subject_id = sc.subject_id
JOIN students s ON s.student_id = sc.student_id
WHERE sc.score = (
    SELECT MAX(score)
    FROM scores sc2
    WHERE sc2.subject_id = sc.subject_id
)
ORDER BY sub.subject_name;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;🔍 Key insight&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Highlights subject toppers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;🎯 3. Subject Difficulty Overview&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;select sub.subject_name,
       round(avg(sc.score),2) as avg_mark,
       max(sc.score) as max_marks,
       min(sc.score) as min_marks,
       (sum(case when sc.score &amp;gt; 50 then 1 end) * 100 / count(*)) as passing_rate
from subjects as sub
join scores sc on sc.subject_id = sub.subject_id
group by sub.subject_name
order by passing_rate;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;🔍 What it tells us&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which subjects are easiest/hardest&lt;/li&gt;
&lt;li&gt;Passing rate trend&lt;/li&gt;
&lt;li&gt;Score distribution visibility&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;📸 Screenshot placeholder:&lt;/p&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.amazonaws.com%2Fuploads%2Farticles%2Fmpz2j9elowhnxbd7qaz7.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.amazonaws.com%2Fuploads%2Farticles%2Fmpz2j9elowhnxbd7qaz7.png" alt=" " width="800" height="313"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;&lt;strong&gt;What I Learned from These Two Projects&lt;/strong&gt;&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt; Window functions (ROW_NUMBER)&lt;/li&gt;
&lt;li&gt; Time-series analysis (DATE_TRUNC, TO_CHAR)&lt;/li&gt;
&lt;li&gt; Business metrics like AOV, revenue, LTV&lt;/li&gt;
&lt;li&gt; CASE expressions for segmentation&lt;/li&gt;
&lt;li&gt; Joining multiple tables to build dashboards&lt;/li&gt;
&lt;li&gt; Thinking like an analyst, not just writing queries&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both projects gave me real-world experience in building analytics dashboards purely using SQL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Final Thoughts&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;These two projects helped me understand how SQL is used in:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;E-commerce analytics&lt;/li&gt;
&lt;li&gt;Education dashboards&lt;/li&gt;
&lt;li&gt;KPI tracking&lt;/li&gt;
&lt;li&gt;Ranking and segmentation&lt;/li&gt;
&lt;li&gt;Time-based reporting&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you're also learning SQL, try replicating these dashboards on any dataset you can find — it’s a great way to level up your analytical thinking. Feedbacks are appreciated&lt;/p&gt;

&lt;p&gt;Data sets used  &lt;/p&gt;

&lt;p&gt;&lt;a href="https://drive.google.com/file/d/1sUgoepDGw4ykCtFyy5H0GbSJ1Alk5XKi/view?usp=sharing" rel="noopener noreferrer"&gt;ecommerce&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://drive.google.com/file/d/1t287B57qMCEIy0MJyUvaQlehlnNijlni/view?usp=sharing" rel="noopener noreferrer"&gt;students&lt;/a&gt;&lt;/p&gt;

</description>
      <category>postgressql</category>
      <category>sideprojects</category>
      <category>database</category>
    </item>
  </channel>
</rss>
