Nginx

Ahmad Hadidi Ahmad Hadidi

Host

Create CT

pct create \
101 \
/mnt/pve/evo/template/cache/alpine-3.18-default_20230607_amd64.tar.xz \
--hostname nginx \
--memory 1024 \
--net0 name=eth0,bridge=vmbr0,firewall=1,gw=192.168.0.1,ip=192.168.0.101/24,hwaddr=DE:AD:DE:AD:01:01,type=veth,ip6=dhcp \
--storage localblock \
--rootfs local-lvm:2 \
--unprivileged 1 \
--ignore-unpack-errors \
--ostype alpine \
--password="123123123" \
--start 1

Enter CT

pct enter 101

# Request IP Address From Router
udhcpc -i eth0 -F $(hostname) -x hostname:$(hostname) -r $(hostname -i)
apk add nginx nano
rc-update add nginx default

In CT

We need to be sure that in Mikrotik there are static DNS records that point to nginx to redirect based on the requested host.

![[101 - Nginx-2025-04.png]]

Install Mappings

nano /etc/nginx/httpd.d/map-backends.conf
map $host $backend {
    default "";
    prox.lan 192.168.0.6:8006;
    shelly.lan 192.168.0.108:80;
    homepage.lan 192.168.0.103:3000;
    adguard.lan 192.168.0.104:80;
    syncthing.lan 192.168.0.105:8384;
    jdownloader.lan 192.168.0.111:5800;
    qbit.lan 192.168.0.110:8080;
    sonarr.lan 192.168.0.112:8989;
    radarr.lan 192.168.0.113:7878;
    ebook.lan 192.168.0.114:5000;
    abs.lan 192.168.0.115:13378;
    emby.lan 192.168.0.116:8096;
    jelly.lan 192.168.0.117:5055;
    prowlarr.lan 192.168.0.118:9696;
    paperless.lan 192.168.0.119:8000;
    immich.lan 192.168.0.120:2283;
    hoarder.lan 192.168.0.121:3000;
    port.lan 192.168.0.203:9000;
    n8n.lan 192.168.0.131:5678;
    change.lan 192.168.0.132:5000;
}

Install LAN Proxy

nano /etc/nginx/httpd.d/lan-proxy.conf
server {
    listen 80;
    server_name ~^(?<appname>.+)\.lan$;

    location / {
        if ($host = prox.lan) {
            return 301 http://192.168.0.6:8006/;
        }

        if ($backend = "") {
            return 404;
        }

        proxy_pass http://$backend;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }

    error_page 404 /lan-404.html;
    error_page 502 /lan-502.html;

    location = /lan-404.html {
        root /etc/nginx/html;
    }

    location = /lan-502.html {
        root /etc/nginx/html;
    }
}

Install Error Page

nano /etc/nginx/html/404-lan.html
<!DOCTYPE html>
<html lang="en">
   <head>
      <meta charset="UTF-8" />
      <meta name="viewport" content="width=device-width, initial-scale=1.0" />
      <title>404 – App Not Found</title>
      <script src="https://cdn.tailwindcss.com"></script>
      <link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;600;700&family=JetBrains+Mono:wght@400;600;700&display=swap" rel="stylesheet">
      <style>
         body {
           font-family: Manrope, Manrope-Fallback, sans-serif;
           text-rendering: optimizeLegibility;
           -webkit-font-smoothing: antialiased;
           -moz-osx-font-smoothing: grayscale;
         }

         code, pre {
           font-family: 'JetBrains Mono', monospace;
           font-size: text-sm
           font-weight: 400;
         }

         @keyframes flicker {
         0%, 100% { opacity: 1; }
         50% { opacity: 0.3; }
         }
         .flicker {
         animation: flicker 1s infinite;
         }
         @keyframes flip {
         0%   { transform: rotateY(0); }
         50%  { transform: rotateY(180deg); }
         100% { transform: rotateY(0); }
         }
         .flip-animation {
         animation: flip 0.5s ease;
         }
         @keyframes toast-slide-up {
         0% {
         transform: translateY(10px);
         opacity: 0;
         }
         100% {
         transform: translateY(0);
         opacity: 1;
         }
         }
         .toast-show {
         animation: toast-slide-up 0.3s ease-out forwards;
         }
      </style>
   </head>
   <body class="grid place-items-center h-screen">
      <!-- Background with blur -->
      <div class="absolute inset-0 z-0">
         <img src="https://staticg.sportskeeda.com/editor/2022/07/707f7-16583224090467-1920.jpg"
            alt="background"
            class="w-full h-full object-cover" />
         <div class="absolute inset-0 bg-slate bg-opacity-60 backdrop-blur-md"></div>
      </div>
      <!-- Main Content -->
      <main class="flex flex-col z-10 max-w-screen-xl px-4 pt-12 pb-8 mx-auto lg:gap-8 xl:gap-0 lg:py-16 lg:grid-cols-12 lg:pt-28">
         <div class="text-5xl mb-12 animate-bounce text-center">⚠</div>
         <h1 class="text-3xl font-bold mb-4 text-center text-amber-400">404</h1>
         <p class="text-lg text-center text-gray-300 max-w-xl mb-4">
            <span id="hostname-text" class="font-semibold text-amber-400">example.lan</span> is not registered in Nginx ☹
         </p>
         <!-- Code -->
         <div class="shadow-md transition-all backdrop-blur-sm rounded-md shadow-md shadow-theme-900/10 dark:shadow-theme-900/20 bg-theme-100/20 hover:bg-theme-300/20 dark:bg-white/5 dark:hover:bg-white/10 relative overflow-clip">
            <div class="w-full text-center text-white rounded-md py-3 bg-opacity-40">
               <code id="command" class="flex items-center space-x-4 text-left text-sm sm:text-base mx-4">
                  <span class="shrink-0 text-gray-500">$</span>
                  <span id="command-text" class="flex-1">
                  add-lan-app example.lan 192.168.0.XXX 1234
                  </span>
                  <svg id="copy-btn" class="shrink-0 h-5 w-5 transition text-gray-500 hover:text-white cursor-pointer" onclick="copyCommand()" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 115.77 122.88" fill="currentColor">
                     <style type="text/css">.st0{fill-rule:evenodd;clip-rule:evenodd;}</style>
                     <g>
                        <path class="st0" d="M89.62,13.96v7.73h12.19h0.01v0.02c3.85,0.01,7.34,1.57,9.86,4.1c2.5,2.51,4.06,5.98,4.07,9.82h0.02v0.02 v73.27v0.01h-0.02c-0.01,3.84-1.57,7.33-4.1,9.86c-2.51,2.5-5.98,4.06-9.82,4.07v0.02h-0.02h-61.7H40.1v-0.02 c-3.84-0.01-7.34-1.57-9.86-4.1c-2.5-2.51-4.06-5.98-4.07-9.82h-0.02v-0.02V92.51H13.96h-0.01v-0.02c-3.84-0.01-7.34-1.57-9.86-4.1 c-2.5-2.51-4.06-5.98-4.07-9.82H0v-0.02V13.96v-0.01h0.02c0.01-3.85,1.58-7.34,4.1-9.86c2.51-2.5,5.98-4.06,9.82-4.07V0h0.02h61.7 h0.01v0.02c3.85,0.01,7.34,1.57,9.86,4.1c2.5,2.51,4.06,5.98,4.07,9.82h0.02V13.96L89.62,13.96z M79.04,21.69v-7.73v-0.02h0.02 c0-0.91-0.39-1.75-1.01-2.37c-0.61-0.61-1.46-1-2.37-1v0.02h-0.01h-61.7h-0.02v-0.02c-0.91,0-1.75,0.39-2.37,1.01 c-0.61,0.61-1,1.46-1,2.37h0.02v0.01v64.59v0.02h-0.02c0,0.91,0.39,1.75,1.01,2.37c0.61,0.61,1.46,1,2.37,1v-0.02h0.01h12.19V35.65 v-0.01h0.02c0.01-3.85,1.58-7.34,4.1-9.86c2.51-2.5,5.98-4.06,9.82-4.07v-0.02h0.02H79.04L79.04,21.69z M105.18,108.92V35.65v-0.02 h0.02c0-0.91-0.39-1.75-1.01-2.37c-0.61-0.61-1.46-1-2.37-1v0.02h-0.01h-61.7h-0.02v-0.02c-0.91,0-1.75,0.39-2.37,1.01 c-0.61,0.61-1,1.46-1,2.37h0.02v0.01v73.27v0.02h-0.02c0,0.91,0.39,1.75,1.01,2.37c0.61,0.61,1.46,1,2.37,1v-0.02h0.01h61.7h0.02 v0.02c0.91,0,1.75-0.39,2.37-1.01c0.61-0.61,1-1.46,1-2.37h-0.02V108.92L105.18,108.92z"/>
                     </g>
                  </svg>
               </code>
            </div>
         </div>
         <!-- Return to Homepage -->
         <a href="http://homepage.lan"
            class="mt-6 text-sm text-slate-300 hover:text-slate-400 hover:text-black pt-4 rounded transition w-auto text-right">
         ← Return to Homepage
         </a>
      </main>
      <!-- Toast Notification -->
      <div id="toast"
         class="fixed bottom-6 right-6 bg-yellow-400 text-black font-medium px-4 py-2 rounded shadow-lg opacity-0 pointer-events-none transition-opacity duration-300 z-50">
         ✅ Copied!
      </div>
      <!-- Script -->
      <script>
         const hostname = window.location.hostname;
         const hostnameTextEl = document.getElementById("hostname-text");
         const commandTextEl = document.getElementById("command-text");
         const copyBtn = document.getElementById("copy-btn");
         const toast = document.getElementById("toast");
         
         // Insert the hostname dynamically
         hostnameTextEl.innerText = hostname;
         commandTextEl.innerHTML = `add-lan-app <span class="text-amber-400">${hostname}</span> 192.168.0.XXX 1234`;
         
         function copyCommand() {
           const textToCopy = commandTextEl.innerText;

           function showToast(message, duration = 1000) {
             toast.textContent = message;
             toast.classList.remove("opacity-0");
             toast.classList.add("toast-show");

             copyBtn.classList.add("animate-ping");

             setTimeout(() => {
               copyBtn.classList.remove("animate-ping");
               toast.classList.remove("toast-show");
               toast.classList.add("opacity-0");
             }, duration);
           }

           if (navigator.clipboard && navigator.clipboard.writeText) {
             // Modern method
             navigator.clipboard.writeText(textToCopy).then(() => {
               showToast("✅ Copied!");
             }).catch(() => {
               showToast("❌ Copy failed", 3000);
             });
           } else {
             // Fallback method
             const textarea = document.createElement("textarea");
             textarea.value = textToCopy;
             document.body.appendChild(textarea);
             textarea.select();

             try {
               const successful = document.execCommand("copy");
               showToast(successful ? "✅ Copied!" : "❌ Copy failed", successful ? 1000 : 3000);
             } catch (err) {
               showToast("❌ Copy failed", 3000);
             }

             document.body.removeChild(textarea);
           }
         }

         function showToast(message) {
           const toast = document.getElementById("toast");
           toast.textContent = message;
           toast.classList.remove("opacity-0");
           toast.classList.add("toast-show");

           setTimeout(() => {
             toast.classList.remove("toast-show");
             toast.classList.add("opacity-0");
           }, 2000);
         }
      </script>
   </body>
</html>

Install 502 Page

nano /etc/nginx/html/lan-502.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>502 – Bad Gateway</title>
  <script src="https://cdn.tailwindcss.com"></script>
  <link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;600;700&family=JetBrains+Mono:wght@400;600;700&display=swap" rel="stylesheet">
  <style>
    body {
      font-family: Manrope, Manrope-Fallback, sans-serif;
      text-rendering: optimizeLegibility;
      -webkit-font-smoothing: antialiased;
      -moz-osx-font-smoothing: grayscale;
    }
    code, pre {
      font-family: 'JetBrains Mono', monospace;
      font-size: 0.875rem; /* text-sm */
      font-weight: 400;
    }
    @keyframes flicker {
      0%, 100% { opacity: 1; }
      50% { opacity: 0.3; }
    }
    .flicker {
      animation: flicker 1s infinite;
    }
    @keyframes flip {
      0% { transform: rotateY(0); }
      50% { transform: rotateY(180deg); }
      100% { transform: rotateY(0); }
    }
    .flip-animation {
      animation: flip 0.5s ease;
    }
    @keyframes toast-slide-up {
      0% {
        transform: translateY(10px);
        opacity: 0;
      }
      100% {
        transform: translateY(0);
        opacity: 1;
      }
    }
    .toast-show {
      animation: toast-slide-up 0.3s ease-out forwards;
    }
  </style>
</head>

<body class="grid place-items-center h-screen">
  <!-- Background with blur -->
  <div class="absolute inset-0 z-0">
    <img src="https://staticg.sportskeeda.com/editor/2022/07/707f7-16583224090467-1920.jpg"
         alt="background"
         class="w-full h-full object-cover" />
    <div class="absolute inset-0 bg-slate bg-opacity-60 backdrop-blur-md"></div>
  </div>

  <!-- Main Content -->
  <main class="flex flex-col z-10 max-w-screen-xl px-4 pt-12 pb-8 mx-auto lg:gap-8 xl:gap-0 lg:py-16 lg:grid-cols-12 lg:pt-28">
    <div class="text-5xl mb-12 animate-bounce text-center">🚧</div>
    <h1 class="text-3xl font-bold mb-4 text-center text-amber-400">502</h1>
    <p class="text-lg text-center text-gray-300 max-w-xl mb-4">
      <span id="hostname-text" class="font-semibold text-amber-400">example.lan</span> is temporarily unavailable 😕
    </p>
    </div>

    <!-- Return to Homepage -->
    <a href="http://homepage.lan"
       class="mt-6 text-sm text-slate-300 hover:text-slate-400 hover:text-black pt-4 rounded transition w-auto text-right">
      ← Return to Homepage
    </a>
  </main>

  <script>
    const hostname = window.location.hostname;
    const hostnameTextEl = document.getElementById("hostname-text");
    const commandTextEl = document.getElementById("command-text");
    const copyBtn = document.getElementById("copy-btn");
    const toast = document.getElementById("toast");

    hostnameTextEl.innerText = hostname;
    commandTextEl.innerHTML = `add-lan-app <span class="text-amber-400">${hostname}</span> 192.168.0.XXX 1234`;

    function copyCommand() {
      const textToCopy = commandTextEl.innerText;

      function showToast(message, duration = 1000) {
        toast.textContent = message;
        toast.classList.remove("opacity-0");
        toast.classList.add("toast-show");

        copyBtn.classList.add("animate-ping");

        setTimeout(() => {
          copyBtn.classList.remove("animate-ping");
          toast.classList.remove("toast-show");
          toast.classList.add("opacity-0");
        }, duration);
      }

      if (navigator.clipboard && navigator.clipboard.writeText) {
        navigator.clipboard.writeText(textToCopy).then(() => {
          showToast("✅ Copied!");
        }).catch(() => {
          showToast("❌ Copy failed", 3000);
        });
      } else {
        const textarea = document.createElement("textarea");
        textarea.value = textToCopy;
        document.body.appendChild(textarea);
        textarea.select();

        try {
          const successful = document.execCommand("copy");
          showToast(successful ? "✅ Copied!" : "❌ Copy failed", successful ? 1000 : 3000);
        } catch (err) {
          showToast("❌ Copy failed", 3000);
        }

        document.body.removeChild(textarea);
      }
    }
  </script>
</body>
</html>

Verification

grep -q 'include /etc/nginx/http\.d/\*\.conf;' /etc/nginx/nginx.conf && echo "✔  include exists" || echo "✖  include missing"

Install add-lan-app

nano /usr/bin/add-lan-app
#!/bin/sh

MAP_FILE="/etc/nginx/http.d/map-backends.conf"

# ANSI Colors
GREEN='\033[1;32m'
RED='\033[1;31m'
YELLOW='\033[38;5;208m'
RESET='\033[0m'

if [ $# -ne 3 ]; then
    printf "${RED}Usage:${RESET} add-lan-app <hostname.lan> <ip> <port>\n"
    exit 1
fi

HOST="$1"
IP="$2"
PORT="$3"

# Check for duplicate
if grep -q "$HOST" "$MAP_FILE"; then
    printf "${YELLOW}⚠  Duplicate:${RESET} Mapping for %s already exists in %s\n" "$HOST" "$MAP_FILE"
    exit 1
fi

printf "Adding %s ➜ %s:%s to %s\n" "$HOST" "$IP" "$PORT" "$MAP_FILE"

# Add to the map
sed -i "/default \"\";/a\    $HOST $IP:$PORT;" "$MAP_FILE"

# Test Nginx config
printf "Testing Nginx config...\n"
if nginx -t 2>/dev/null; then
    printf "Reloading Nginx...\n"
    nginx -s reload
    printf "${GREEN}✔  Success:${RESET} %s ➜ http://%s:%s has been mapped and Nginx reloaded.\n" "$HOST" "$IP" "$PORT"
else
    printf "${RED}✖  Error:${RESET} Invalid Nginx config. Rolling back.\n"
    sed -i "/$HOST $IP:$PORT;/d" "$MAP_FILE"
    nginx -t
    exit 1
fi
chmod +x /usr/bin/add-lan-app

add-lan-app test.lan 192.168.0.000 8989

Cheatsheet

# log of nginx access
cat /var/log/nginx/access.log

# nginx conf
nano /etc/nginx/nginx.conf

# register a new website after hooking it up in the router
add-lan-app <app.lan> <lxc-ip-address> <app-port>

# nginx 'sites-available' on alpine
nano /etc/nginx/http.d/lan-proxy.conf
nano /etc/nginx/http.d/map-backends.conf

# reload nginx's config
nginx -t && nginx -s reload

# nginx error log (set level to debug in nginx.conf)
tail -f /var/log/nginx/error.log