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