<?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: Rasheed Bustamam</title>
    <description>The latest articles on DEV Community by Rasheed Bustamam (@abustamam).</description>
    <link>https://dev.to/abustamam</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F922058%2F708f4776-95ca-4b22-a066-89da2d1d1876.jpeg</url>
      <title>DEV Community: Rasheed Bustamam</title>
      <link>https://dev.to/abustamam</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/abustamam"/>
    <language>en</language>
    <item>
      <title>NGINX Load Balancing, Failover &amp; TLS on a VPS</title>
      <dc:creator>Rasheed Bustamam</dc:creator>
      <pubDate>Wed, 25 Feb 2026 23:30:48 +0000</pubDate>
      <link>https://dev.to/abustamam/nginx-load-balancing-failover-tls-on-a-vps-414c</link>
      <guid>https://dev.to/abustamam/nginx-load-balancing-failover-tls-on-a-vps-414c</guid>
      <description>&lt;p&gt;&lt;em&gt;Using the tools of titans&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In our previous post, we built an L7 load balancer using Caddy reverse proxy. In this post, we'll migrate that configuration over to nginx so we can compare tradeoffs. But first, what is nginx?&lt;/p&gt;

&lt;h2&gt;
  
  
  What is Nginx?
&lt;/h2&gt;

&lt;p&gt;NGINX, or nginx because I don't like screaming (pronounced engine-x, though some folks will say en-jinx), is a high-performance HTTP server and reverse proxy commonly used for load balancing, TLS termination, and serving static content.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note: for the purposes of this guide, when nginx is written, it should be assumed it's nginx OSS and not nginx's enterprise offering, nginx Plus. Ensure that when reading docs about nginx, you are reading docs about nginx OSS, typically hosted at &lt;a href="http://nginx.org" rel="noopener noreferrer"&gt;nginx.org&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Where Caddy optimizes for simplicity and automatic TLS, nginx exposes lower-level control over request routing, buffering, and upstream behavior. In our previous setup, Caddy handled reverse proxying and active health checks across two upstream nodes.&lt;/p&gt;

&lt;p&gt;In this migration, we move to nginx to gain explicit control over upstream pools, failure detection, and connection timeouts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preconditions
&lt;/h2&gt;

&lt;p&gt;I'm assuming you read the last post. If not, here are our baseline assumptions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Domain &lt;code&gt;bustamam.tech&lt;/code&gt; A record points to &lt;strong&gt;server-1 public IP&lt;/strong&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;server-1 and server-2 are on the same Hetzner private network&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;server-2 exposes app on private IP/port: &lt;code&gt;10.0.0.3:3100 -&amp;gt; container:3000&lt;/code&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To confirm, from an ssh session in server-1, run this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://10.0.0.3:3100/api/whoami
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that returns &lt;code&gt;server-2&lt;/code&gt; (or whatever your SERVER_ID is) then we can continue.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;curl http://10.0.0.3:3100/api/whoami
&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"message"&lt;/span&gt;:&lt;span class="s2"&gt;"hello from server bustamam-tech-2"&lt;/span&gt;,&lt;span class="s2"&gt;"serverId"&lt;/span&gt;:&lt;span class="s2"&gt;"bustamam-tech-2"&lt;/span&gt;,&lt;span class="s2"&gt;"pid"&lt;/span&gt;:1,&lt;span class="s2"&gt;"time"&lt;/span&gt;:&lt;span class="s2"&gt;"2026-02-24T22:34:37.462Z"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Basic nginx scaffold
&lt;/h2&gt;

&lt;p&gt;Right now, our app looks something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Internet (HTTPS)
        ↓
     Caddy
        ↓
   App containers
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Caddy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Listens on 80/443 (http/s)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Owns the TLS cert&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Decrypts HTTPS to HTTP&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Forwards HTTP to upstream containers&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Does L7 load balancing between them&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We are going to introduce nginx as the new &lt;strong&gt;edge reverse proxy&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That means nginx will do the same exact thing, and we'll remove Caddy from the loop.&lt;/p&gt;

&lt;p&gt;The new architecture becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Internet (HTTPS)
        ↓
     nginx
        ↓
   App containers
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We are not:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Changing the app&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Changing Docker build&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Changing the private network&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Moving certs to backend servers&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Doing TLS passthrough&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We are just replacing Caddy with nginx as the &lt;strong&gt;TLS-terminating L7 proxy&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;TLS termination at nginx does the following:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;It keeps certificates in one place&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;It allows HTTP-aware load balancing&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;It lets nginx inspect requests if needed&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;It simplifies backend containers (they only speak HTTP)&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is a common production pattern.&lt;/p&gt;

&lt;p&gt;In order for all of this to work, we need three things:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. nginx needs config files&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So it knows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;which domain it serves&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;where to proxy traffic&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;where the cert files are&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can check out the documentation on nginx web servers &lt;a href="https://docs.nginx.com/nginx/admin-guide/web-server/web-server/" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. nginx needs certificates&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Let's Encrypt cert + private key must live in a mounted volume.&lt;/p&gt;

&lt;p&gt;Documentation on nginx certs &lt;a href="https://docs.nginx.com/nginx-gateway-fabric/traffic-security/integrate-cert-manager/" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. nginx needs to expose ports 80 and 443&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Because it becomes the public entrypoint.&lt;/p&gt;

&lt;p&gt;Let's start with the config. On your load balancer server, run the following:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; nginx/conf.d
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is where your nginx site configs will live. The location is arbitrary -- we will map it to a docker volume.&lt;/p&gt;

&lt;p&gt;OK, let's spin nginx up. Update your &lt;code&gt;docker-compose.yml&lt;/code&gt; file:&lt;br&gt;
&lt;/p&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="c1"&gt;# caddy, your app, etc&lt;/span&gt;
  &lt;span class="na"&gt;nginx&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;nginx:1.27-alpine&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx&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="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8080:8080"&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;./nginx/conf.d:/etc/nginx/conf.d:ro&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;bustamam-tech&lt;/span&gt;&lt;span class="pi"&gt;]&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then, create a file called &lt;code&gt;00-shadow.conf&lt;/code&gt; in your &lt;code&gt;conf.d&lt;/code&gt; directory.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note: conf files' names are not super important, but nginx does load them in alphabetical/numerical order, so it's a common practice to prepend with 00 for sorting purposes.&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="c"&gt;# Shadow nginx: runs on :8080 so we can test without touching Caddy (:80/:443)
&lt;/span&gt;
&lt;span class="n"&gt;upstream&lt;/span&gt; &lt;span class="n"&gt;bustamam_upstreams&lt;/span&gt; {
  &lt;span class="c"&gt;# round robin + retry-on-failure behavior are nginx defaults
&lt;/span&gt;  &lt;span class="n"&gt;server&lt;/span&gt; &lt;span class="n"&gt;bustamam&lt;/span&gt;-&lt;span class="n"&gt;tech&lt;/span&gt;:&lt;span class="m"&gt;3000&lt;/span&gt;;
  &lt;span class="n"&gt;server&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;3&lt;/span&gt;:&lt;span class="m"&gt;3100&lt;/span&gt;;

  &lt;span class="c"&gt;# Note: we're currently relying on nginx's default passive health checks
&lt;/span&gt;}


&lt;span class="n"&gt;server&lt;/span&gt; {
  &lt;span class="n"&gt;listen&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;;
  &lt;span class="n"&gt;server_name&lt;/span&gt; &lt;span class="err"&gt;_&lt;/span&gt;;

  &lt;span class="n"&gt;location&lt;/span&gt; / {
    &lt;span class="n"&gt;proxy_pass&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;://&lt;span class="n"&gt;bustamam_upstreams&lt;/span&gt;;

    &lt;span class="c"&gt;# Minimum headers to keep apps happy
&lt;/span&gt;    &lt;span class="n"&gt;proxy_set_header&lt;/span&gt; &lt;span class="n"&gt;Host&lt;/span&gt; $&lt;span class="n"&gt;host&lt;/span&gt;;
    &lt;span class="n"&gt;proxy_set_header&lt;/span&gt; &lt;span class="n"&gt;X&lt;/span&gt;-&lt;span class="n"&gt;Forwarded&lt;/span&gt;-&lt;span class="n"&gt;Proto&lt;/span&gt; $&lt;span class="n"&gt;scheme&lt;/span&gt;;
    &lt;span class="n"&gt;proxy_set_header&lt;/span&gt; &lt;span class="n"&gt;X&lt;/span&gt;-&lt;span class="n"&gt;Forwarded&lt;/span&gt;-&lt;span class="n"&gt;For&lt;/span&gt; $&lt;span class="n"&gt;proxy_add_x_forwarded_for&lt;/span&gt;;
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What this does:&lt;/strong&gt; nginx is now an L7 proxy and can load balance, but it's intentionally naive.&lt;/p&gt;

&lt;p&gt;Let's spin up our nginx service.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up nginx &lt;span class="nt"&gt;-d&lt;/span&gt;

&lt;span class="c"&gt;# after it's running&lt;/span&gt;

docker compose ps

&lt;span class="c"&gt;# you should see your nginx service, as well as any other service that might be running&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now you can do the loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;1..10&lt;span class="o"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://bustamam.tech:8080/api/whoami&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;Note: this is http, not https, and note the port as well, it matches the port in the .conf file.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You should get alternating server IDs. If you don't, double check your config!&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing Failover
&lt;/h2&gt;

&lt;p&gt;Let's pull down server-2 for a second and try this again. &lt;code&gt;docker compose down&lt;/code&gt; on server-2. Then try curl again.&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%2Fdx59alyu0wjfzzaw09lt.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%2Fdx59alyu0wjfzzaw09lt.png" alt="verifying failover" width="800" height="581"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Uh-oh! We're hanging where the round robin would have sent us to server-2! Let's fix that.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note: nginx has some pretty long defaults, so while it may feel like forever, it might be something like 60 seconds. While it is said that patience is a virtue, a user won't use an app that takes 60 seconds to load or fetch data! Timeouts are your first production knob.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Handling Failover
&lt;/h2&gt;

&lt;p&gt;Let's update our config so we don't wait forever when a destination server is down.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;  &lt;span class="n"&gt;location&lt;/span&gt; / {
    &lt;span class="n"&gt;proxy_pass&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;://&lt;span class="n"&gt;bustamam_upstreams&lt;/span&gt;;

    &lt;span class="n"&gt;proxy_connect_timeout&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;; &lt;span class="c"&gt;# timeout for connecting to the upstream https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_connect_timeout
&lt;/span&gt;    &lt;span class="n"&gt;proxy_read_timeout&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;; &lt;span class="c"&gt;# timeout for reading the response from the upstream https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_read_timeout
&lt;/span&gt;
    &lt;span class="c"&gt;# Minimum headers to keep apps happy
&lt;/span&gt;    &lt;span class="n"&gt;proxy_set_header&lt;/span&gt; &lt;span class="n"&gt;Host&lt;/span&gt; $&lt;span class="n"&gt;host&lt;/span&gt;;
    &lt;span class="n"&gt;proxy_set_header&lt;/span&gt; &lt;span class="n"&gt;X&lt;/span&gt;-&lt;span class="n"&gt;Forwarded&lt;/span&gt;-&lt;span class="n"&gt;Proto&lt;/span&gt; $&lt;span class="n"&gt;scheme&lt;/span&gt;;
    &lt;span class="n"&gt;proxy_set_header&lt;/span&gt; &lt;span class="n"&gt;X&lt;/span&gt;-&lt;span class="n"&gt;Forwarded&lt;/span&gt;-&lt;span class="n"&gt;For&lt;/span&gt; $&lt;span class="n"&gt;proxy_add_x_forwarded_for&lt;/span&gt;;
  }  
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Restart nginx on server-1&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose restart nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;Note: for the remainder of this post, we will assume that a config change is followed by container restart.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And try it again!&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%2Ftdgtl6fbjt5q7k80r3xd.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%2Ftdgtl6fbjt5q7k80r3xd.png" alt="verifying failover" width="800" height="581"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Great! But wait, if server-2 is down, how long are we waiting before nginx sends the request to server-1? Let's instrument some observability. Update your &lt;code&gt;location&lt;/code&gt; config so we have access to the upstream IP addresses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  location / {
    proxy_pass http://bustamam_upstreams;

    proxy_connect_timeout 1s; # timeout for connecting to the upstream https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_connect_timeout
    proxy_read_timeout 5s; # timeout for reading the response from the upstream https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_read_timeout

    # Minimum headers to keep apps happy
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    add_header X-Upstream $upstream_addr always;
  }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And let's try a slightly different bash script.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;1..10&lt;span class="o"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"---- &lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="s2"&gt; ----"&lt;/span&gt;

  curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-D&lt;/span&gt; headers.txt &lt;span class="se"&gt;\&lt;/span&gt;
       &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;code=%{http_code} time=%{time_total}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
       http://bustamam.tech:8080/api/whoami

  &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; x-upstream headers.txt
  &lt;span class="nb"&gt;echo
&lt;/span&gt;&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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%2Ftrul8jsywxdtg1zdc5qc.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%2Ftrul8jsywxdtg1zdc5qc.png" alt="verifying latency" width="800" height="655"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Aha! Notice that even though we're getting 200's and getting the right server to respond, look at the third one. We added a whole second to our latency, and you can see that a request attempted to go to server-2 in the X-Upstream header. Even when the request succeeds, failover can still cost you a timeout. Success isn't the same as fast.&lt;/p&gt;

&lt;p&gt;Let's flesh this out a bit more. Let's update our upstream config. Defaults exist, but we want our system to be able to explain itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="c"&gt;# Shadow nginx: runs on :8080 so we can test without touching Caddy (:80/:443)
&lt;/span&gt;
&lt;span class="n"&gt;upstream&lt;/span&gt; &lt;span class="n"&gt;bustamam_upstreams&lt;/span&gt; {
  &lt;span class="c"&gt;# primary server with default settings
&lt;/span&gt;  &lt;span class="c"&gt;# note that because this service lives on this machine, if this server is down, the nginx container will also be down.
&lt;/span&gt;  &lt;span class="n"&gt;server&lt;/span&gt; &lt;span class="n"&gt;bustamam&lt;/span&gt;-&lt;span class="n"&gt;tech&lt;/span&gt;:&lt;span class="m"&gt;3000&lt;/span&gt;;

  &lt;span class="c"&gt;# secondary server with custom settings
&lt;/span&gt;  &lt;span class="c"&gt;# max_fails=1
&lt;/span&gt;  &lt;span class="c"&gt;#   If 1 request fails within the fail_timeout window,
&lt;/span&gt;  &lt;span class="c"&gt;#   mark this upstream as "unavailable".
&lt;/span&gt;  &lt;span class="c"&gt;#
&lt;/span&gt;  &lt;span class="c"&gt;# fail_timeout=10s
&lt;/span&gt;  &lt;span class="c"&gt;#   How long to consider that backend "down" before retrying it.
&lt;/span&gt;  &lt;span class="c"&gt;#
&lt;/span&gt;  &lt;span class="n"&gt;server&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;3&lt;/span&gt;:&lt;span class="m"&gt;3100&lt;/span&gt; &lt;span class="n"&gt;max_fails&lt;/span&gt;=&lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="n"&gt;fail_timeout&lt;/span&gt;=&lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;;
}

&lt;span class="n"&gt;server&lt;/span&gt; {
  &lt;span class="n"&gt;listen&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;;
  &lt;span class="n"&gt;server_name&lt;/span&gt; &lt;span class="err"&gt;_&lt;/span&gt;;

  &lt;span class="n"&gt;location&lt;/span&gt; / {
    &lt;span class="n"&gt;proxy_pass&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;://&lt;span class="n"&gt;bustamam_upstreams&lt;/span&gt;;

    &lt;span class="n"&gt;proxy_connect_timeout&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;; &lt;span class="c"&gt;# timeout for connecting to the upstream https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_connect_timeout
&lt;/span&gt;    &lt;span class="n"&gt;proxy_read_timeout&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;; &lt;span class="c"&gt;# timeout for reading the response from the upstream https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_read_timeout
&lt;/span&gt;
    &lt;span class="c"&gt;# Minimum headers to keep apps happy
&lt;/span&gt;    &lt;span class="n"&gt;proxy_set_header&lt;/span&gt; &lt;span class="n"&gt;Host&lt;/span&gt; $&lt;span class="n"&gt;host&lt;/span&gt;;
    &lt;span class="n"&gt;proxy_set_header&lt;/span&gt; &lt;span class="n"&gt;X&lt;/span&gt;-&lt;span class="n"&gt;Forwarded&lt;/span&gt;-&lt;span class="n"&gt;Proto&lt;/span&gt; $&lt;span class="n"&gt;scheme&lt;/span&gt;;
    &lt;span class="n"&gt;proxy_set_header&lt;/span&gt; &lt;span class="n"&gt;X&lt;/span&gt;-&lt;span class="n"&gt;Forwarded&lt;/span&gt;-&lt;span class="n"&gt;For&lt;/span&gt; $&lt;span class="n"&gt;proxy_add_x_forwarded_for&lt;/span&gt;;
    &lt;span class="n"&gt;add_header&lt;/span&gt; &lt;span class="n"&gt;X&lt;/span&gt;-&lt;span class="n"&gt;Upstream&lt;/span&gt; $&lt;span class="n"&gt;upstream_addr&lt;/span&gt; &lt;span class="n"&gt;always&lt;/span&gt;;


    &lt;span class="c"&gt;# Note: this is nginx's default https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_next_upstream
&lt;/span&gt;    &lt;span class="n"&gt;proxy_next_upstream&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;;

    &lt;span class="c"&gt;# how many retries to attempt before giving up https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_next_upstream_tries
&lt;/span&gt;    &lt;span class="n"&gt;proxy_next_upstream_tries&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;; &lt;span class="c"&gt;# default is 0, which means unbounded retries!
&lt;/span&gt;  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let's quickly talk about &lt;code&gt;max_fails&lt;/code&gt;, &lt;code&gt;fail_timeout&lt;/code&gt;, &lt;code&gt;proxy_next_upstream&lt;/code&gt; and &lt;code&gt;proxy_next_upstream_tries&lt;/code&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;max_fails&lt;/code&gt; / &lt;code&gt;fail_timeout&lt;/code&gt; is upstream-level &lt;strong&gt;passive failure marking&lt;/strong&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;proxy_next_upstream&lt;/code&gt; / &lt;code&gt;tries&lt;/code&gt; is request-level &lt;strong&gt;retry routing&lt;/strong&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Think of this as two layers: upstream marking (which servers are considered eligible) and per-request retries (what nginx does when a request fails mid-flight).&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note: unbounded does not mean infinite. To be more explicit, 0 means &lt;strong&gt;no explicit limit&lt;/strong&gt; (i.e., not bounded by tries). In practice, retries are still bounded by timeouts and available upstreams, but it's not a safe default if you're trying to reason about worst-case latency.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Feel free to play around with numbers! For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;server&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;3&lt;/span&gt;:&lt;span class="m"&gt;3100&lt;/span&gt; &lt;span class="n"&gt;max_fails&lt;/span&gt;=&lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="n"&gt;fail_timeout&lt;/span&gt;=&lt;span class="m"&gt;60&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now if this server fails once, then it won't be tried again for another 60 seconds. But it also means that if the server came back up, no one would be able to access it for 60 seconds. This is where metrics and understanding your system as a whole is important. For a toy project like this, even 10+ minutes would be fine.&lt;/p&gt;

&lt;p&gt;The trade-off is fast recovery (low timeout) vs minimizing probe spikes (high timeout).&lt;/p&gt;

&lt;p&gt;You can also tighten up the connect timeout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;proxy_connect_timeout&lt;/span&gt; &lt;span class="m"&gt;200&lt;/span&gt;&lt;span class="n"&gt;ms&lt;/span&gt;;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But this only works if your private network is reliably fast. If it ever takes more than 200ms for the server to respond, you may mark an otherwise healthy server as dead due to jitter.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note: If you're running into issues with your config, you should the difference between upstream definition (where servers live) and proxy behavior (how requests fail over). Mixing them up leads to configs that look reasonable but don't load, and the failure mode is "nothing works, and you're not sure why" unless you validate with &lt;code&gt;nginx -t&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So, let's summarize what nginx is doing so far.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;nginx tries server-2 (round robin)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;it fails, and nginx marks it 'down" for ~10s&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;for the next ~10 seconds, nginx only uses server-1 (fast)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;once the 10s window expires, nginx will &lt;strong&gt;probe&lt;/strong&gt; server-2 again by selecting it for a real request&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;that request pays the 1s connect timeout (your ~1.5s)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;nginx retries server-1 and succeeds&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;server-2 gets marked down again for another 10 seconds&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;if server-2 ever comes back up, then any probes will mark server-2 back online&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So it seems like we're at parity with Caddy, right? Well, unfortunately, no. We still need TLS termination. Let's handle that next.&lt;/p&gt;

&lt;h2&gt;
  
  
  TLS Termination
&lt;/h2&gt;

&lt;p&gt;Right now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Caddy&lt;/strong&gt; terminates TLS on &lt;code&gt;:443&lt;/code&gt; and proxies to your backends.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;nginx&lt;/strong&gt; is shadow-testing on &lt;code&gt;:8080&lt;/code&gt; (plain HTTP).&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;TLS termination means:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The client's HTTPS connection ends at nginx.&lt;/strong&gt; nginx decrypts the request, then forwards it to your upstreams over plain HTTP (usually over a private network/VPC).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Browser ⇄ &lt;strong&gt;HTTPS&lt;/strong&gt; ⇄ nginx (edge)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;nginx ⇄ &lt;strong&gt;HTTP&lt;/strong&gt; ⇄ upstreams (private)&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's what we mean when we say "terminate TLS at the load balancer."&lt;/p&gt;

&lt;p&gt;Our plan is to replace Caddy. We want the following:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;nginx serves &lt;strong&gt;HTTP on :80&lt;/strong&gt; and handles the Let's Encrypt ACME challenge&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;certbot obtains certs via webroot&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;nginx serves &lt;strong&gt;HTTPS on :443&lt;/strong&gt; using those certs and proxies to upstreams&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;shut down Caddy (to free 80/443), bring up nginx+certbot&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Redirect http to https
&lt;/h3&gt;

&lt;p&gt;Alright, we'll need some new directories for configs and certs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; nginx/www nginx/letsencrypt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your directory structure should look something like this:&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%2Fy1inwrvcft12ywalhz8i.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%2Fy1inwrvcft12ywalhz8i.png" alt="directory structure for configs" width="298" height="138"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I have a few extra files from messing around with configs. And again, the directory names are arbitrary. We'll get them mapped in docker. Important to understand that certbot doesn't "talk to nginx." They just share a filesystem. Certbot writes files. nginx serves them. That's it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;nginx/www&lt;/code&gt; is where the ACME challenge files are written. When Let's Encrypt validates your domain, it requests &lt;code&gt;http://bustamam.tech/.well-known/acme-challenge/&amp;lt;token&amp;gt;&lt;/code&gt; . Certbot writes that token file into your &lt;code&gt;www/&lt;/code&gt; directory, and nginx will serve that directory.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;nginx/letsencrypt&lt;/code&gt; is where certs live (shared with nginx). When certbot succeeds, it writes cert files into: &lt;code&gt;/etc/letsencrypt/live/bustamam.tech/&lt;/code&gt; . So whatever local directory maps to &lt;code&gt;/etc/letsencrypt&lt;/code&gt; must also be shared between &lt;code&gt;certbot&lt;/code&gt; (read/write) and nginx (read-only).&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;Note: for more information on ACME and other Let's Encrypt challenges, check out their &lt;a href="https://letsencrypt.org/docs/challenge-types/" rel="noopener noreferrer"&gt;documentation&lt;/a&gt; on challenge types&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Let's delete everything in conf.d and start with a fresh config: &lt;code&gt;bustamam.tech.conf&lt;/code&gt; (or whatever you wanna name it)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="c"&gt;# ================================
# Upstreams
# ================================
&lt;/span&gt;&lt;span class="n"&gt;upstream&lt;/span&gt; &lt;span class="n"&gt;bustamam_upstreams&lt;/span&gt; {
  &lt;span class="c"&gt;# Primary (local container)
&lt;/span&gt;  &lt;span class="n"&gt;server&lt;/span&gt; &lt;span class="n"&gt;bustamam&lt;/span&gt;-&lt;span class="n"&gt;tech&lt;/span&gt;:&lt;span class="m"&gt;3000&lt;/span&gt;;

  &lt;span class="c"&gt;# Secondary (remote server over private network)
&lt;/span&gt;  &lt;span class="n"&gt;server&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;3&lt;/span&gt;:&lt;span class="m"&gt;3100&lt;/span&gt; &lt;span class="n"&gt;max_fails&lt;/span&gt;=&lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="n"&gt;fail_timeout&lt;/span&gt;=&lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;;
}

&lt;span class="c"&gt;# ================================
# HTTP (port 80)
# - Serve ACME challenge
# - Redirect everything else to HTTPS
# ================================
&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt; {
  &lt;span class="n"&gt;listen&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;;
  &lt;span class="n"&gt;server_name&lt;/span&gt; &lt;span class="n"&gt;bustamam&lt;/span&gt;.&lt;span class="n"&gt;tech&lt;/span&gt;;

  &lt;span class="c"&gt;# Let's Encrypt HTTP-01 challenge files live here
&lt;/span&gt;  &lt;span class="n"&gt;location&lt;/span&gt; /.&lt;span class="n"&gt;well&lt;/span&gt;-&lt;span class="n"&gt;known&lt;/span&gt;/&lt;span class="n"&gt;acme&lt;/span&gt;-&lt;span class="n"&gt;challenge&lt;/span&gt;/ {
    &lt;span class="n"&gt;root&lt;/span&gt; /&lt;span class="n"&gt;var&lt;/span&gt;/&lt;span class="n"&gt;www&lt;/span&gt;/&lt;span class="n"&gt;certbot&lt;/span&gt;;
  }

  &lt;span class="c"&gt;# Everything else goes to HTTPS
&lt;/span&gt;  &lt;span class="n"&gt;location&lt;/span&gt; / {
    &lt;span class="n"&gt;return&lt;/span&gt; &lt;span class="m"&gt;301&lt;/span&gt; &lt;span class="n"&gt;https&lt;/span&gt;://$&lt;span class="n"&gt;host&lt;/span&gt;$&lt;span class="n"&gt;request_uri&lt;/span&gt;;
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Footgun:&lt;/strong&gt; We are purposely deferring https for later in this article. If you enable the &lt;code&gt;listen 443 ssl&lt;/code&gt; server block before certs exist, nginx may fail to start, and you'll see port 80 "hang" because nothing is listening. The bootstrap sequence is: HTTP first → obtain cert → enable HTTPS.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;OK, now we need to update our &lt;code&gt;docker-compose.yml&lt;/code&gt; file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;  &lt;span class="na"&gt;nginx&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;nginx:1.27-alpine&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx&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="s2"&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="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;443:443"&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;./nginx/conf.d:/etc/nginx/conf.d:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./nginx/www:/var/www/certbot:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./nginx/letsencrypt:/etc/letsencrypt:ro&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;bustamam-tech&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;certbot&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;certbot/certbot:latest&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;certbot&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;./nginx/www:/var/www/certbot:rw&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./nginx/letsencrypt:/etc/letsencrypt:rw&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Important to note:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;nginx mounts certs directory &lt;strong&gt;read-only&lt;/strong&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;certbot mounts cert directory &lt;strong&gt;read-write&lt;/strong&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now let's bring our creation to life.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bring nginx up on port 80, test http
&lt;/h3&gt;

&lt;p&gt;Caddy is currently occupying ports 80 and 443. So if you have Caddy running, bring it down with &lt;code&gt;docker compose down caddy&lt;/code&gt;. Then, bring up nginx. If it's already running, run &lt;code&gt;docker compose restart nginx&lt;/code&gt;. Otherwise, &lt;code&gt;docker compose up nginx -d&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Then test http connection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-I&lt;/span&gt; http://bustamam.tech
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see a 301 redirect to https, which is exactly what we want.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note: if this hangs, you may need to debug if the services are running on the ports. Try running this on the host machine: &lt;code&gt;sudo ss -lntp | grep -E ':80|:443'&lt;/code&gt; and starting there.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;But we don't have https set up. Let's go do that.&lt;/p&gt;

&lt;h3&gt;
  
  
  Set up https
&lt;/h3&gt;

&lt;p&gt;Let's update our conf file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="c"&gt;# ================================
# Upstreams
# ================================
&lt;/span&gt;&lt;span class="n"&gt;upstream&lt;/span&gt; &lt;span class="n"&gt;bustamam_upstreams&lt;/span&gt; {
  &lt;span class="c"&gt;# Primary (local container)
&lt;/span&gt;  &lt;span class="n"&gt;server&lt;/span&gt; &lt;span class="n"&gt;bustamam&lt;/span&gt;-&lt;span class="n"&gt;tech&lt;/span&gt;:&lt;span class="m"&gt;3000&lt;/span&gt;;

  &lt;span class="c"&gt;# Secondary (remote server over private network)
&lt;/span&gt;  &lt;span class="n"&gt;server&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;3&lt;/span&gt;:&lt;span class="m"&gt;3100&lt;/span&gt; &lt;span class="n"&gt;max_fails&lt;/span&gt;=&lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="n"&gt;fail_timeout&lt;/span&gt;=&lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;;
}

&lt;span class="c"&gt;# ================================
# HTTP (port 80)
# - Serve ACME challenge
# - Redirect everything else to HTTPS
# ================================
&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt; {
  &lt;span class="n"&gt;listen&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;;
  &lt;span class="n"&gt;server_name&lt;/span&gt; &lt;span class="n"&gt;bustamam&lt;/span&gt;.&lt;span class="n"&gt;tech&lt;/span&gt;;

  &lt;span class="c"&gt;# Let's Encrypt HTTP-01 challenge files live here
&lt;/span&gt;  &lt;span class="n"&gt;location&lt;/span&gt; /.&lt;span class="n"&gt;well&lt;/span&gt;-&lt;span class="n"&gt;known&lt;/span&gt;/&lt;span class="n"&gt;acme&lt;/span&gt;-&lt;span class="n"&gt;challenge&lt;/span&gt;/ {
    &lt;span class="n"&gt;root&lt;/span&gt; /&lt;span class="n"&gt;var&lt;/span&gt;/&lt;span class="n"&gt;www&lt;/span&gt;/&lt;span class="n"&gt;certbot&lt;/span&gt;;
  }

  &lt;span class="c"&gt;# Everything else goes to HTTPS
&lt;/span&gt;  &lt;span class="n"&gt;location&lt;/span&gt; / {
    &lt;span class="n"&gt;return&lt;/span&gt; &lt;span class="m"&gt;301&lt;/span&gt; &lt;span class="n"&gt;https&lt;/span&gt;://$&lt;span class="n"&gt;host&lt;/span&gt;$&lt;span class="n"&gt;request_uri&lt;/span&gt;;
  }
}

&lt;span class="c"&gt;# ================================
# HTTPS (port 443)
# - Terminate TLS here
# - Reverse proxy to upstreams over HTTP
# ================================
&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt; {
  &lt;span class="n"&gt;listen&lt;/span&gt; &lt;span class="m"&gt;443&lt;/span&gt; &lt;span class="n"&gt;ssl&lt;/span&gt;;
  &lt;span class="n"&gt;server_name&lt;/span&gt; &lt;span class="n"&gt;bustamam&lt;/span&gt;.&lt;span class="n"&gt;tech&lt;/span&gt;;

  &lt;span class="c"&gt;# TLS certs (provided by certbot via shared volume)
&lt;/span&gt;  &lt;span class="n"&gt;ssl_certificate&lt;/span&gt;     /&lt;span class="n"&gt;etc&lt;/span&gt;/&lt;span class="n"&gt;letsencrypt&lt;/span&gt;/&lt;span class="n"&gt;live&lt;/span&gt;/&lt;span class="n"&gt;bustamam&lt;/span&gt;.&lt;span class="n"&gt;tech&lt;/span&gt;/&lt;span class="n"&gt;fullchain&lt;/span&gt;.&lt;span class="n"&gt;pem&lt;/span&gt;;
  &lt;span class="n"&gt;ssl_certificate_key&lt;/span&gt; /&lt;span class="n"&gt;etc&lt;/span&gt;/&lt;span class="n"&gt;letsencrypt&lt;/span&gt;/&lt;span class="n"&gt;live&lt;/span&gt;/&lt;span class="n"&gt;bustamam&lt;/span&gt;.&lt;span class="n"&gt;tech&lt;/span&gt;/&lt;span class="n"&gt;privkey&lt;/span&gt;.&lt;span class="n"&gt;pem&lt;/span&gt;;

  &lt;span class="c"&gt;# A minimal modern TLS posture
&lt;/span&gt;  &lt;span class="n"&gt;ssl_protocols&lt;/span&gt; &lt;span class="n"&gt;TLSv1&lt;/span&gt;.&lt;span class="m"&gt;2&lt;/span&gt; &lt;span class="n"&gt;TLSv1&lt;/span&gt;.&lt;span class="m"&gt;3&lt;/span&gt;;

  &lt;span class="n"&gt;location&lt;/span&gt; / {
    &lt;span class="n"&gt;proxy_pass&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;://&lt;span class="n"&gt;bustamam_upstreams&lt;/span&gt;;

    &lt;span class="c"&gt;# Fail fast
&lt;/span&gt;    &lt;span class="n"&gt;proxy_connect_timeout&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;;
    &lt;span class="n"&gt;proxy_read_timeout&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;;
    &lt;span class="n"&gt;proxy_send_timeout&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;;

    &lt;span class="c"&gt;# Deterministic retry behavior (make defaults explicit)
&lt;/span&gt;    &lt;span class="n"&gt;proxy_next_upstream&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt; &lt;span class="n"&gt;http_502&lt;/span&gt; &lt;span class="n"&gt;http_503&lt;/span&gt; &lt;span class="n"&gt;http_504&lt;/span&gt;;
    &lt;span class="n"&gt;proxy_next_upstream_tries&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;;

    &lt;span class="c"&gt;# Forwarding headers
&lt;/span&gt;    &lt;span class="n"&gt;proxy_set_header&lt;/span&gt; &lt;span class="n"&gt;Host&lt;/span&gt; $&lt;span class="n"&gt;host&lt;/span&gt;;
    &lt;span class="n"&gt;proxy_set_header&lt;/span&gt; &lt;span class="n"&gt;X&lt;/span&gt;-&lt;span class="n"&gt;Forwarded&lt;/span&gt;-&lt;span class="n"&gt;Proto&lt;/span&gt; &lt;span class="n"&gt;https&lt;/span&gt;;
    &lt;span class="n"&gt;proxy_set_header&lt;/span&gt; &lt;span class="n"&gt;X&lt;/span&gt;-&lt;span class="n"&gt;Forwarded&lt;/span&gt;-&lt;span class="n"&gt;For&lt;/span&gt; $&lt;span class="n"&gt;proxy_add_x_forwarded_for&lt;/span&gt;;

    &lt;span class="c"&gt;# Debug: show which upstream served (or was attempted)
&lt;/span&gt;    &lt;span class="n"&gt;add_header&lt;/span&gt; &lt;span class="n"&gt;X&lt;/span&gt;-&lt;span class="n"&gt;Upstream&lt;/span&gt; $&lt;span class="n"&gt;upstream_addr&lt;/span&gt; &lt;span class="n"&gt;always&lt;/span&gt;;
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The http part (port 80) is the same. https is just a barebones skeleton with some sensible defaults. The ssl_certificates don't exist yet though, so let's make those.&lt;/p&gt;

&lt;h3&gt;
  
  
  Obtain the certificates
&lt;/h3&gt;

&lt;p&gt;Let's start with a test cert. In your host machine, run this command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose run &lt;span class="nt"&gt;--rm&lt;/span&gt; certbot certonly &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--webroot&lt;/span&gt; &lt;span class="nt"&gt;-w&lt;/span&gt; /var/www/certbot &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; bustamam.tech &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--test-cert&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--agree-tos&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-m&lt;/span&gt; rasheed.bustamam@gmail.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--no-eff-email&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It'll probably pull from docker, and when it succeeds, you should see a bunch of stuff appear under your &lt;code&gt;letsencrypt&lt;/code&gt; directory:&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%2F8az385f0de19f1fore3x.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%2F8az385f0de19f1fore3x.png" alt="certs and other files in letsencrypt dir" width="295" height="530"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If yes, then rerun the command without the &lt;code&gt;test-cert&lt;/code&gt; flag.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose run &lt;span class="nt"&gt;--rm&lt;/span&gt; certbot certonly &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--webroot&lt;/span&gt; &lt;span class="nt"&gt;-w&lt;/span&gt; /var/www/certbot &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; bustamam.tech &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--agree-tos&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-m&lt;/span&gt; rasheed.bustamam@gmail.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--no-eff-email&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's possible this will ask you to reuse your current cert, or create a new one. Choose to create a new one; you can't use a test cert in production environments.&lt;/p&gt;

&lt;p&gt;Now let's restart nginx so it can read our new certs!&lt;/p&gt;

&lt;h3&gt;
  
  
  Activate https in nginx
&lt;/h3&gt;

&lt;p&gt;Just run&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose restart nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-I&lt;/span&gt; https://bustamam.tech
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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%2F4pgwvtnc94dfclcrd8uo.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%2F4pgwvtnc94dfclcrd8uo.png" alt="load balancing still working, and https working as well using curl" width="800" height="662"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Let's test our whoami route too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://bustamam.tech/api/whoami
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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%2Fggzmswmm4ai91xb14kgv.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%2Fggzmswmm4ai91xb14kgv.png" alt="whoami route correctly load balances as well" width="800" height="662"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Now we have https working and our load balancer is still working!&lt;/p&gt;

&lt;p&gt;Now, I have to note -- since we are managing our own certs, we also have to renew it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose run &lt;span class="nt"&gt;--rm&lt;/span&gt; certbot renew &lt;span class="nt"&gt;--webroot&lt;/span&gt; &lt;span class="nt"&gt;-w&lt;/span&gt; /var/www/certbot
docker &lt;span class="nb"&gt;exec &lt;/span&gt;nginx nginx &lt;span class="nt"&gt;-s&lt;/span&gt; reload
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can run this on a cronjob if you'd like, but it's not in the scope of this article.&lt;/p&gt;

&lt;h2&gt;
  
  
  Comparison to Caddy
&lt;/h2&gt;

&lt;p&gt;Now that we finally got parity with Caddy, let's compare!&lt;/p&gt;

&lt;p&gt;As a reminder, this was our &lt;code&gt;Caddyfile&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bustamam.tech {
    reverse_proxy bustamam-tech:3000 10.0.0.3:3100 {
        lb_policy round_robin

        # total retry window across upstreams
        lb_try_duration 3s             

        # how often to retry upstreams within that window
        lb_try_interval 250ms

        # Active health checking
        health_uri /api/healthz
        health_interval 5s
        health_timeout 2s

        # How long to consider a backend "down" after failures (circuit breaker window)
        # duration to keep an upstream marked as unhealthy
        fail_duration 10s              

        # threshold of failures before marking an upstream down
        max_fails 1                    

        # Fail fast when an upstream is unresponsive
        transport http {
            # TCP connect timeout to the upstream
            dial_timeout 1s            

            # slow backend detection (time waiting for first byte)
            response_header_timeout 2s 
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We got active health checking and automatic TLS issuance and renewal. And then this was nginx:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="c"&gt;# ================================
# Upstreams
# ================================
&lt;/span&gt;&lt;span class="n"&gt;upstream&lt;/span&gt; &lt;span class="n"&gt;bustamam_upstreams&lt;/span&gt; {
  &lt;span class="c"&gt;# Primary (local container)
&lt;/span&gt;  &lt;span class="n"&gt;server&lt;/span&gt; &lt;span class="n"&gt;bustamam&lt;/span&gt;-&lt;span class="n"&gt;tech&lt;/span&gt;:&lt;span class="m"&gt;3000&lt;/span&gt;;

  &lt;span class="c"&gt;# Secondary (remote server over private network)
&lt;/span&gt;  &lt;span class="n"&gt;server&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;3&lt;/span&gt;:&lt;span class="m"&gt;3100&lt;/span&gt; &lt;span class="n"&gt;max_fails&lt;/span&gt;=&lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="n"&gt;fail_timeout&lt;/span&gt;=&lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;;
}

&lt;span class="c"&gt;# ================================
# HTTP (port 80)
# - Serve ACME challenge
# - Redirect everything else to HTTPS
# ================================
&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt; {
  &lt;span class="n"&gt;listen&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;;
  &lt;span class="n"&gt;server_name&lt;/span&gt; &lt;span class="n"&gt;bustamam&lt;/span&gt;.&lt;span class="n"&gt;tech&lt;/span&gt;;

  &lt;span class="c"&gt;# Let's Encrypt HTTP-01 challenge files live here
&lt;/span&gt;  &lt;span class="n"&gt;location&lt;/span&gt; /.&lt;span class="n"&gt;well&lt;/span&gt;-&lt;span class="n"&gt;known&lt;/span&gt;/&lt;span class="n"&gt;acme&lt;/span&gt;-&lt;span class="n"&gt;challenge&lt;/span&gt;/ {
    &lt;span class="n"&gt;root&lt;/span&gt; /&lt;span class="n"&gt;var&lt;/span&gt;/&lt;span class="n"&gt;www&lt;/span&gt;/&lt;span class="n"&gt;certbot&lt;/span&gt;;
  }

  &lt;span class="c"&gt;# Everything else goes to HTTPS
&lt;/span&gt;  &lt;span class="n"&gt;location&lt;/span&gt; / {
    &lt;span class="n"&gt;return&lt;/span&gt; &lt;span class="m"&gt;301&lt;/span&gt; &lt;span class="n"&gt;https&lt;/span&gt;://$&lt;span class="n"&gt;host&lt;/span&gt;$&lt;span class="n"&gt;request_uri&lt;/span&gt;;
  }
}

&lt;span class="c"&gt;# ================================
# HTTPS (port 443)
# - Terminate TLS here
# - Reverse proxy to upstreams over HTTP
# ================================
&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt; {
  &lt;span class="n"&gt;listen&lt;/span&gt; &lt;span class="m"&gt;443&lt;/span&gt; &lt;span class="n"&gt;ssl&lt;/span&gt;;
  &lt;span class="n"&gt;server_name&lt;/span&gt; &lt;span class="n"&gt;bustamam&lt;/span&gt;.&lt;span class="n"&gt;tech&lt;/span&gt;;

  &lt;span class="c"&gt;# TLS certs (provided by certbot via shared volume)
&lt;/span&gt;  &lt;span class="n"&gt;ssl_certificate&lt;/span&gt;     /&lt;span class="n"&gt;etc&lt;/span&gt;/&lt;span class="n"&gt;letsencrypt&lt;/span&gt;/&lt;span class="n"&gt;live&lt;/span&gt;/&lt;span class="n"&gt;bustamam&lt;/span&gt;.&lt;span class="n"&gt;tech&lt;/span&gt;/&lt;span class="n"&gt;fullchain&lt;/span&gt;.&lt;span class="n"&gt;pem&lt;/span&gt;;
  &lt;span class="n"&gt;ssl_certificate_key&lt;/span&gt; /&lt;span class="n"&gt;etc&lt;/span&gt;/&lt;span class="n"&gt;letsencrypt&lt;/span&gt;/&lt;span class="n"&gt;live&lt;/span&gt;/&lt;span class="n"&gt;bustamam&lt;/span&gt;.&lt;span class="n"&gt;tech&lt;/span&gt;/&lt;span class="n"&gt;privkey&lt;/span&gt;.&lt;span class="n"&gt;pem&lt;/span&gt;;

  &lt;span class="c"&gt;# A minimal modern TLS posture
&lt;/span&gt;  &lt;span class="n"&gt;ssl_protocols&lt;/span&gt; &lt;span class="n"&gt;TLSv1&lt;/span&gt;.&lt;span class="m"&gt;2&lt;/span&gt; &lt;span class="n"&gt;TLSv1&lt;/span&gt;.&lt;span class="m"&gt;3&lt;/span&gt;;

  &lt;span class="n"&gt;location&lt;/span&gt; / {
    &lt;span class="n"&gt;proxy_pass&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;://&lt;span class="n"&gt;bustamam_upstreams&lt;/span&gt;;

    &lt;span class="c"&gt;# Fail fast
&lt;/span&gt;    &lt;span class="n"&gt;proxy_connect_timeout&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;;
    &lt;span class="n"&gt;proxy_read_timeout&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;;
    &lt;span class="n"&gt;proxy_send_timeout&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;;

    &lt;span class="c"&gt;# Deterministic retry behavior (make defaults explicit)
&lt;/span&gt;    &lt;span class="n"&gt;proxy_next_upstream&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt; &lt;span class="n"&gt;http_502&lt;/span&gt; &lt;span class="n"&gt;http_503&lt;/span&gt; &lt;span class="n"&gt;http_504&lt;/span&gt;;
    &lt;span class="n"&gt;proxy_next_upstream_tries&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;;

    &lt;span class="c"&gt;# Forwarding headers
&lt;/span&gt;    &lt;span class="n"&gt;proxy_set_header&lt;/span&gt; &lt;span class="n"&gt;Host&lt;/span&gt; $&lt;span class="n"&gt;host&lt;/span&gt;;
    &lt;span class="n"&gt;proxy_set_header&lt;/span&gt; &lt;span class="n"&gt;X&lt;/span&gt;-&lt;span class="n"&gt;Forwarded&lt;/span&gt;-&lt;span class="n"&gt;Proto&lt;/span&gt; &lt;span class="n"&gt;https&lt;/span&gt;;
    &lt;span class="n"&gt;proxy_set_header&lt;/span&gt; &lt;span class="n"&gt;X&lt;/span&gt;-&lt;span class="n"&gt;Forwarded&lt;/span&gt;-&lt;span class="n"&gt;For&lt;/span&gt; $&lt;span class="n"&gt;proxy_add_x_forwarded_for&lt;/span&gt;;

    &lt;span class="c"&gt;# Debug: show which upstream served (or was attempted)
&lt;/span&gt;    &lt;span class="n"&gt;add_header&lt;/span&gt; &lt;span class="n"&gt;X&lt;/span&gt;-&lt;span class="n"&gt;Upstream&lt;/span&gt; $&lt;span class="n"&gt;upstream_addr&lt;/span&gt; &lt;span class="n"&gt;always&lt;/span&gt;;
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We get passive health checking, and then we needed Certbot to manage our certs for us.&lt;/p&gt;

&lt;p&gt;So you may be asking, "Why is nginx better than Caddy??" and the answer is that it isn't, not necessarily. &lt;strong&gt;Caddy is the better default for small systems. nginx is better when you need explicit control, standardized ops, or you're operating inside a bigger ecosystem.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Doing nginx here isn't "because it's better," it's because it teaches you how the edge actually works when the platform stops holding your hand.&lt;/p&gt;

&lt;p&gt;Caddy can keep a backend out of rotation &lt;strong&gt;before&lt;/strong&gt; a user hits it. nginx usually learns a backend is dead &lt;strong&gt;because&lt;/strong&gt; a user hit it (or because passive marking was configured).&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Caddy is the better default for small systems. nginx is better when you need explicit control, standardized ops, or you're operating inside a bigger ecosystem.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It's important to call out that we don't want to be comparing "lines of config" when evaluating tools. It's a matter of what you own vs what you delegate.&lt;/p&gt;

&lt;h3&gt;
  
  
  Caddy: batteries included, opinionated defaults
&lt;/h3&gt;

&lt;p&gt;We got, almost for free:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;automatic TLS issuance/renewal&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;active health checks&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;nice LB ergonomics (&lt;code&gt;health_uri&lt;/code&gt;, &lt;code&gt;fail_duration&lt;/code&gt;, etc.)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;fewer footguns&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;10-ish lines of config&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So for a $20 VPS and learning, Caddy is amazing.&lt;/p&gt;

&lt;h3&gt;
  
  
  nginx OSS: modular and explicit
&lt;/h3&gt;

&lt;p&gt;We had to build the edge out of primitives:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;TLS is not automatic (had to use certbot)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;health checks are passive unless you add extra machinery&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;reload behavior and config validation are on you&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;you need to understand contexts (&lt;code&gt;upstream&lt;/code&gt; vs &lt;code&gt;location&lt;/code&gt;) or you break it&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;about 60-ish lines of config&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That pain is the point: nginx forces us to learn the contract between:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;TCP port binding&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;TLS termination&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;request routing&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;retries/timeouts&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;failure detection&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;certificate lifecycle&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the systems knowledge that we're trying to learn in the first place.&lt;/p&gt;

&lt;h3&gt;
  
  
  When to Choose nginx over Caddy
&lt;/h3&gt;

&lt;p&gt;Ideally, your team is already using one and you just need to learn it :)&lt;/p&gt;

&lt;p&gt;But for greenfield projects, or for understanding when to migrate from Caddy to nginx:&lt;/p&gt;

&lt;h4&gt;
  
  
  1) When you need a boring industry standard
&lt;/h4&gt;

&lt;p&gt;nginx is everywhere. If you join a team with existing nginx infra, knowing it is immediate leverage.&lt;/p&gt;

&lt;h4&gt;
  
  
  2) When you need predictable, explicit behavior at the edge
&lt;/h4&gt;

&lt;p&gt;In nginx you can be extremely specific about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;what counts as retryable&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;how many tries&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;timeouts per phase (connect/send/read)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;failure semantics per upstream&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Caddy has knobs too, but nginx's model maps closely to how a lot of production stacks think.&lt;/p&gt;

&lt;h4&gt;
  
  
  3) When the ecosystem around it matters
&lt;/h4&gt;

&lt;p&gt;nginx has deep integration patterns with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;legacy deployments&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;enterprise tooling&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;common security hardening playbooks&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;common debugging muscle memory (every SRE has done &lt;code&gt;nginx -T&lt;/code&gt;, &lt;code&gt;nginx -t&lt;/code&gt;, reloads, etc.)&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  4) When performance tuning at massive scale is the job
&lt;/h4&gt;

&lt;p&gt;At large companies, nobody is choosing nginx because "it's faster" per request in isolation. They're choosing because:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;they know how to operate it safely&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;they know how it fails&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;it has predictable resource profiles and instrumentation patterns&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The interesting part isn't that we got it working. It's that we can now &lt;em&gt;explain&lt;/em&gt; worst-case latency: connect timeout + number of tries + fail_timeout window. That's the difference between 'it seems fine' and 'I can predict how it fails.'&lt;/p&gt;

&lt;h3&gt;
  
  
  Conclusion
&lt;/h3&gt;

&lt;p&gt;For my $20 VPS and my hobby projects, Caddy is obviously the better tool. It's simpler, safer, and gives me active health checks and automatic TLS with almost no ceremony.&lt;br&gt;&lt;br&gt;
I rebuilt it in nginx anyway because nginx makes the hidden parts visible: TLS bootstrapping, reload semantics, passive vs active failure detection, and how retries interact with timeouts. Those are the concepts that scale, and that's the whole point of this series.&lt;/p&gt;

&lt;p&gt;In the next post, we'll actually go in the opposite direction -- we'll use a managed service to do all of this for us. See you there!&lt;/p&gt;

</description>
      <category>webdev</category>
    </item>
    <item>
      <title>Building a Load Balancer from Scratch on a $20 VPS</title>
      <dc:creator>Rasheed Bustamam</dc:creator>
      <pubDate>Tue, 24 Feb 2026 21:32:28 +0000</pubDate>
      <link>https://dev.to/abustamam/building-a-load-balancer-from-scratch-on-a-20-vps-2neo</link>
      <guid>https://dev.to/abustamam/building-a-load-balancer-from-scratch-on-a-20-vps-2neo</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;The journey to a million users begins with a simple load balancer&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you’ve deployed a web app, you’ve already built the first 10% of a scalable system.&lt;br&gt;&lt;br&gt;
The next 10% is learning what happens when one server isn’t enough, and what failure feels like in production.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If you’ve never deployed a web app, start here: &lt;a href="https://www.reddit.com/r/vibecoding/comments/1pj6ngp/how_to_deploy_your_app_to_the_web_for_beginners/" rel="noopener noreferrer"&gt;Reddit Guide&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In this post, I’ll recreate the "load balancer" chapter from Alex Xu’s &lt;a href="https://bytes.usc.edu/~saty/courses/docs/data/SystemDesignInterview.pdf" rel="noopener noreferrer"&gt;&lt;em&gt;System Design Interview&lt;/em&gt;&lt;/a&gt; on a $20 VPS, using Caddy as an L7 reverse proxy. The goal isn’t novelty, it’s building intuition for retries, health checks, and failover.&lt;/p&gt;
&lt;h2&gt;
  
  
  Baseline Assumptions
&lt;/h2&gt;

&lt;p&gt;I had my &lt;a href="https://bustamam.tech" rel="noopener noreferrer"&gt;portfolio site&lt;/a&gt; deployed on a Hetzner VPS. The app is a Tanstack Start app, but we are assuming you have something similar to the following:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;An app that runs in a container on port 3000.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A CI process builds/pushes to GHCR.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A server pulls latest via compose.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once you have an app that lives on a server somewhere, we are good to go.&lt;/p&gt;
&lt;h2&gt;
  
  
  Load Balancing
&lt;/h2&gt;

&lt;p&gt;Now what if I post a cool project on Hacker News and it goes viral and I get thousands of people looking at my site? My poor half-CPU server would probably melt. I &lt;em&gt;could&lt;/em&gt; scale vertically and just add more compute. But let's say it went &lt;em&gt;really&lt;/em&gt; viral and I got millions of people looking at my cool project! Well, you can't have a single machine with infinite CPU.&lt;/p&gt;

&lt;p&gt;That's where the load balancer comes in. A load balancer is also a &lt;strong&gt;failure detector&lt;/strong&gt; and a &lt;strong&gt;policy engine&lt;/strong&gt;: it decides where traffic goes and how quickly it gives up when a backend misbehaves. It can do a few things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;It can distribute traffic among several servers. This assumes your app is largely stateless (or that state lives in shared systems like a DB/Redis).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;If one server is down, your app can still work because it exists on the other ones&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Without a load balancer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;One server → single point of failure (SPOF)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;One server → limited CPU&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;One server → deployment risk&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Load balancing is the first real step from "app" to "system." Think of it as traffic control: each request gets directed to a healthy server.&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%2F7ndzcdp2u4j5e3xivbk3.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%2F7ndzcdp2u4j5e3xivbk3.png" alt="load balance cop metaphor" width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Of course this can cause some funny behavior! If server 1 had one version of an app and server 2 had another version, then you can easily run into a situation where you see a bug happen in one user's session and not another. This is where observability comes into play, but that is not in the scope of this blog post.&lt;/p&gt;

&lt;p&gt;There are two types of load balancers: L4 and L7.&lt;/p&gt;
&lt;h3&gt;
  
  
  L4 Load Balancing
&lt;/h3&gt;

&lt;p&gt;L4 load balancing is on the transport layer. L4 is concerned with TCP/UDP forwarding, but is blind to HTTP routes. In other words, it allows you to route requests to different servers without knowing what is in the request. Thus, you cannot inspect the MIME type or URL of the request, for example, or any of its contents. But it's used because it's fast.&lt;/p&gt;
&lt;h3&gt;
  
  
  L7 Load Balancing
&lt;/h3&gt;

&lt;p&gt;L7 is on the application layer. L7 understands HTTP, can route by path/headers, can do HTTP health checks. For example, we can route requests to &lt;code&gt;/api&lt;/code&gt; to our API server, but all other requests go to our web app server.&lt;/p&gt;

&lt;p&gt;For the purposes of this article, we will be focusing on L7 load balancing using Caddy reverse proxy. I'm using L7 because it’s the most common "first load balancer" in web stacks and it's where practical concerns like health checks and timeouts show up fast.&lt;/p&gt;
&lt;h2&gt;
  
  
  Wiring two backends behind one entrypoint
&lt;/h2&gt;

&lt;p&gt;So, how do you set up a load balancer? Well, I spun up another small Hetzner server in the same network zone (in my case, &lt;code&gt;eu-central&lt;/code&gt;). I copied the bustamam-tech portion of the docker-compose file to the new server. I ran &lt;code&gt;docker compose up -d&lt;/code&gt; to spin up my portfolio site on that server.&lt;/p&gt;

&lt;p&gt;Then, I set up a private network on Hetzner. Go to your Hetzner project and go to Networks. Click on Create Network.&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%2F8gpnlly7eumxrhv6wqp8.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%2F8gpnlly7eumxrhv6wqp8.png" alt="create network modal" width="629" height="337"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You can name your network whatever you want. I called mine load-balancer-test. You can change the name later. Ensure the network zone matches the same network zone of the two servers. Then, for IP range, you can leave that as default.&lt;/p&gt;

&lt;p&gt;In case you're curious about what the IP range means though, this &lt;a href="https://stackoverflow.com/questions/76520302/what-is-ipv4-cidr-while-launching-a-custom-vpc-in-aws" rel="noopener noreferrer"&gt;Stack Overflow post&lt;/a&gt; explains what the IP range means, and you can do more research into it. But suffice to say, it allocates a bunch of private IP addresses for the resources you put into your network. the &lt;code&gt;16&lt;/code&gt; means that you get around 65k IP addresses, which ought to be plenty.&lt;/p&gt;

&lt;p&gt;Then, click into your network and click on "Attach Resources." Click on your servers and you should see the private IP addresses your resources have.&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%2Fas1muq1sbzatw3xz42r1.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%2Fas1muq1sbzatw3xz42r1.png" alt="networks" width="800" height="148"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note: I used &lt;code&gt;/24&lt;/code&gt; for my IP range; this gives me 256 unique IP addresses which should suffice for my experimentation&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Now your servers can talk to each other! Let's add a basic &lt;code&gt;/api/whoami&lt;/code&gt; route to our app so we know which server we're talking to.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createFileRoute&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@tanstack/react-router&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@tanstack/react-start&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getServerId&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SERVER_ID&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unknown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;Route&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createFileRoute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api/whoami&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)({&lt;/span&gt;
  &lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;handlers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;serverId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getServerId&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
          &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`hello from server &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;serverId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nx"&gt;serverId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&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;Great, now let's update your &lt;code&gt;docker-compose.yml&lt;/code&gt; files on both of our servers.&lt;/p&gt;

&lt;p&gt;server-1:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;  &lt;span class="na"&gt;bustamam-tech&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/abustamam/bustamam-tech:latest&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bustamam-tech&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;SERVER_ID&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bustamam-tech-1&lt;/span&gt;
    &lt;span class="na"&gt;expose&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3000"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice the addition of the SERVER_ID env var.&lt;/p&gt;

&lt;p&gt;server-2:&lt;br&gt;
&lt;/p&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;bustamam-tech&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/abustamam/bustamam-tech:latest&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bustamam-tech&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;SERVER_ID&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bustamam-tech-2&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="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10.0.0.3:3100:3000"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On server-2, we bind the container to its private IP so other machines in the network can reach it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note: 10.0.0.3 is the private IP address of server-2.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Now, try doing an API call from one server to another (for example, from bustamam-tech-1 I ran &lt;code&gt;http://10.0.0.3:3100/api/whoami&lt;/code&gt; and got a response).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;root@bustamam-tech-1: &lt;span class="nv"&gt;$ &lt;/span&gt;curl http://10.0.0.3:3100/api/whoami

&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"message"&lt;/span&gt;:&lt;span class="s2"&gt;"hello from server bustamam-tech-2"&lt;/span&gt;,&lt;span class="s2"&gt;"serverId"&lt;/span&gt;:&lt;span class="s2"&gt;"bustamam-tech-2"&lt;/span&gt;,&lt;span class="s2"&gt;"pid"&lt;/span&gt;:1,&lt;span class="s2"&gt;"time"&lt;/span&gt;:&lt;span class="s2"&gt;"2026-02-24T18:15:13.089Z"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hooray! There's a lot of neat stuff you can do just by setting up a private network (shared DBs, etc), but we'll probably tackle that later in the series.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note: For cost, I colocated the load balancer and one backend on the same box. This is a SPOF (single point of failure). In production, the LB should be an independent failure domain (or managed).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;To set up your load balancer using Caddy, update your Caddyfile to reference your other server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bustamam.tech {
    reverse_proxy bustamam-tech:3000 10.0.0.3:3100 {
        lb_policy round_robin
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice that we added &lt;code&gt;10.0.0.3:3100&lt;/code&gt; as a target. If we were using a dedicated server for load balancing, then we'd need to update &lt;code&gt;bustamam-tech:3000&lt;/code&gt; in a similar fashion and use its private IP address, but since we are using the server on which the &lt;code&gt;bustamam-tech&lt;/code&gt; service is running, we can just refer to the server name.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;lb_policy round_robin&lt;/code&gt; means that the load balancer will forward each request to each server once before starting over from the first one. There's a bunch of other algorithms; another commonly used one is &lt;code&gt;least_conn&lt;/code&gt; which will connect the request to the server with the fewest connections. For more information, check out the &lt;a href="https://caddyserver.com/docs/caddyfile/directives/reverse_proxy#lb_policy" rel="noopener noreferrer"&gt;Caddy docs&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;And that's it! Restart your Caddy server &lt;code&gt;docker compose restart caddy&lt;/code&gt; and then from a terminal outside of your Hetzner network (like running locally), run the following bash command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;1..10&lt;span class="o"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do &lt;/span&gt;curl &lt;span class="o"&gt;{&lt;/span&gt;your_whoami_route&lt;span class="o"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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%2Fu6n6c8job1fg5jb1cncb.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%2Fu6n6c8job1fg5jb1cncb.png" alt="alternating requests" width="800" height="534"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you properly see the requests alternating, congrats! You just set up your own load balancer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failover: the first real production footgun
&lt;/h2&gt;

&lt;p&gt;Let's test failover. If server 2 goes down, then the load balancer should only ever connect to server 1.&lt;/p&gt;

&lt;p&gt;Run &lt;code&gt;docker compose down&lt;/code&gt; on server 2 to simulate the server being down. Then run the bash script again.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;1..10&lt;span class="o"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do &lt;/span&gt;curl &lt;span class="o"&gt;{&lt;/span&gt;your_whoami_route&lt;span class="o"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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%2Futjqwvtfct2gqsgrs1l2.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%2Futjqwvtfct2gqsgrs1l2.png" alt="no failover" width="800" height="534"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Uh oh! Notice the 5 seconds of latency? This means failover isn't working. We need a way for our load balancer to know if a server is up or not. There's a few ways to do this, but a common solution is to use a health check, which is a route that basically says "I'm alive!".&lt;/p&gt;

&lt;p&gt;Why health checks? It doesn't actually do anything, right? It's just a route that returns static OK.&lt;/p&gt;

&lt;p&gt;Well, this is why we have health checks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Without health checks, the LB only discovers failure after a request fails.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;That means user-facing latency spikes.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Health checks convert reactive detection into proactive detection.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Without health checks, failover happens only after a user request times out. That means your users become your monitoring system, which is not an ideal experience for them.&lt;/p&gt;

&lt;p&gt;Well, we already have a &lt;code&gt;/api/whoami&lt;/code&gt; route that doesn't do anything but return an environment variable. Let's use that.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bustamam.tech {
    reverse_proxy bustamam-tech:3000 10.0.0.3:3100 {
        lb_policy round_robin

        # Active health checking
        health_uri /api/whoami
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;Note: in many production environments, the health check route is at a route like &lt;code&gt;/api/healthz&lt;/code&gt; or &lt;code&gt;/api/healthcheck&lt;/code&gt; that just returns &lt;code&gt;{ OK: true }&lt;/code&gt; or something like that. I'll leave that as an exercise for the reader to implement if interested.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Restart your Caddy service (&lt;code&gt;docker compose restart caddy&lt;/code&gt;), then try it again.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;1..10&lt;span class="o"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do &lt;/span&gt;curl &lt;span class="o"&gt;{&lt;/span&gt;your_whoami_route&lt;span class="o"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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%2Ffsip0i08zpvsqwa6ag7d.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%2Ffsip0i08zpvsqwa6ag7d.png" alt="failover success" width="800" height="534"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Yay! All of our traffic is being routed to server 1!&lt;/p&gt;

&lt;p&gt;So, what happened during the failover process?&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Caddy picked server-2 (round robin)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;server-2 was down&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;the client waited for any connect/response timeouts&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;only then did Caddy tried the next upstream&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Without health checks, your first failure is paid for by a real user.&lt;/p&gt;

&lt;p&gt;The final config I went with is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bustamam.tech {
    reverse_proxy bustamam-tech:3000 10.0.0.3:3100 {
        lb_policy round_robin

        # total retry window across upstreams
        lb_try_duration 3s             

        # how often to retry upstreams within that window
        lb_try_interval 250ms

        # Active health checking
        health_uri /api/healthz # note I changed this from /api/whoami
        health_interval 5s
        health_timeout 2s

        # How long to consider a backend “down” after failures (circuit breaker window)
        # duration to keep an upstream marked as unhealthy
        fail_duration 10s              

        # threshold of failures before marking an upstream down
        max_fails 1                    

        # Fail fast when an upstream is unresponsive
        transport http {
            # TCP connect timeout to the upstream
            dial_timeout 1s            

            # slow backend detection (time waiting for first byte)
            response_header_timeout 2s 
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Again, refer to the &lt;a href="https://caddyserver.com/docs/caddyfile/directives/reverse_proxy#load-balancing" rel="noopener noreferrer"&gt;Caddy docs&lt;/a&gt; for more information on the config. These settings are basically: &lt;strong&gt;bounded retries&lt;/strong&gt;, &lt;strong&gt;active health checks&lt;/strong&gt;, and a &lt;strong&gt;circuit breaker&lt;/strong&gt;, plus aggressive &lt;strong&gt;timeouts&lt;/strong&gt; so failure is detected quickly. Rule of thumb: timeouts first, retries second. Retries without timeouts just turn slow failures into traffic jams.&lt;/p&gt;

&lt;p&gt;And there we have it! A complete load balancer in just a few lines of code.&lt;/p&gt;

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

&lt;p&gt;At this point, you've moved from deploying an app to operating a system.&lt;/p&gt;

&lt;p&gt;We explored L7 load balancing. We set up a second server to host our web app, we used Caddy to implement failover, and we watched it work before our eyes. Best of all, this didn't require a lot of code!&lt;/p&gt;

&lt;p&gt;Notably though, we did not cover:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Database replication&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Session stickiness&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Deployment coordination&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Distributed logging&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Observability&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We will tackle these later in the series.&lt;/p&gt;

&lt;p&gt;Caddy is great for reverse proxies and basic L7 load balancing. But many companies will expect you to also know how to set up a load balancer in nginx or HAProxy. Next: nginx or HAProxy, and why teams choose one over the other (operability, observability, failure semantics).&lt;/p&gt;

</description>
      <category>webdev</category>
    </item>
    <item>
      <title>Why Design for Scale?</title>
      <dc:creator>Rasheed Bustamam</dc:creator>
      <pubDate>Tue, 24 Feb 2026 21:24:53 +0000</pubDate>
      <link>https://dev.to/abustamam/why-design-for-scale-6i6</link>
      <guid>https://dev.to/abustamam/why-design-for-scale-6i6</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Or, "can't we just use more compute?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Preface
&lt;/h2&gt;

&lt;p&gt;Hi, I’m Rasheed Bustamam.&lt;/p&gt;

&lt;p&gt;I’ve been a full-stack engineer since 2015. I’ve worked with and consulted for startups, served as a founding engineer, and been part of multiple successful exits. In many circles, that’s considered startup gold.&lt;/p&gt;

&lt;p&gt;But here’s the gap: while I’ve built fast, shipped quickly, and prototyped aggressively, I haven’t had deep exposure to scale.&lt;/p&gt;

&lt;p&gt;Not real scale.&lt;/p&gt;

&lt;p&gt;What does “scale” even mean?&lt;br&gt;&lt;br&gt;
Is it user volume?&lt;br&gt;&lt;br&gt;
Geographic distribution?&lt;br&gt;&lt;br&gt;
Latency under load?&lt;br&gt;&lt;br&gt;
Operational complexity?&lt;/p&gt;

&lt;p&gt;Different companies optimize for different things. And I realized that while I understood how to build features, I didn’t deeply understand how to design systems that hold up under pressure.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Turning Point
&lt;/h2&gt;

&lt;p&gt;Five years ago, I interviewed at Google and was asked to “design the Google search bar.”&lt;/p&gt;

&lt;p&gt;At the time, my mental model was:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“Isn’t it just a text input that calls &lt;code&gt;GET /api/search&lt;/code&gt;?”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Needless to say, I didn’t get the job.&lt;/p&gt;

&lt;p&gt;But five years later, I should be able to answer that question.&lt;/p&gt;

&lt;p&gt;So I decided to fix that.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Plan
&lt;/h2&gt;

&lt;p&gt;I started with &lt;a href="https://bytes.usc.edu/~saty/courses/docs/data/SystemDesignInterview.pdf" rel="noopener noreferrer"&gt;&lt;em&gt;System Design Interview: An Insider’s Guide&lt;/em&gt;&lt;/a&gt; by Alex Xu as a structured entry point into systems thinking.&lt;/p&gt;

&lt;p&gt;The book is high-level and intentionally generic. It discusses concepts like load balancers, caching, replication, and data partitioning -- but not how to actually implement them in a real environment.&lt;/p&gt;

&lt;p&gt;So I’m doing both:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Studying the concepts&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Building them myself to solidify the understanding&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I’ll experiment with infrastructure, set up load balancers, configure services, and test failure scenarios -- not just talk about them.&lt;/p&gt;

&lt;p&gt;I may use AI to help synthesize information, but I won’t rely on AI to implement the systems for me. The goal is understanding, not automation.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Write This Publicly?
&lt;/h2&gt;

&lt;p&gt;This series is primarily for me.&lt;/p&gt;

&lt;p&gt;But I suspect I’m not alone.&lt;/p&gt;

&lt;p&gt;There are many engineers who:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Ship quickly&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Build great product experiences&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Have deep frontend or application-level expertise&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;But feel underprepared when conversations shift toward distributed systems and scalability&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If that sounds familiar, this series might resonate with you.&lt;/p&gt;




&lt;h2&gt;
  
  
  What to Expect
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Clear breakdowns of system design concepts&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Practical implementations&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Tradeoffs and failure modes&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Reflections on what “scale” actually means in different contexts&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Lessons learned from hands-on experiments&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No hype. No pretending to be an expert.&lt;/p&gt;

&lt;p&gt;Just deliberate, structured growth.&lt;/p&gt;




&lt;p&gt;If you have questions, ideas, or critiques -- I’d love to hear them.&lt;/p&gt;

&lt;p&gt;Let’s learn this properly.&lt;/p&gt;

&lt;p&gt;If you're in, leave a comment and say that you're in!&lt;/p&gt;

</description>
      <category>webdev</category>
    </item>
  </channel>
</rss>
