nginx Configuration Architecture
nginx is the only public-facing service in Lamaste. It handles TLS termination, mTLS enforcement, reverse proxying, forward authentication, and WebSocket upgrades for all traffic.
In Plain English
Every request to Lamaste passes through nginx first. For the admin panel, nginx checks that your browser has a valid client certificate before letting the request through. For tunneled apps, nginx asks Authelia to verify the user's identity. For tunnel connections from your machine, nginx upgrades the connection to a WebSocket and forwards it to the Chisel tunnel server.
nginx is the gatekeeper. The services behind it (Panel Server, Authelia, Chisel) all bind to 127.0.0.1 and are invisible from the internet. nginx is what makes them accessible — securely and selectively.
Overview
Internet
│
├── :80 (every hostname)
│ └── lamalibre-lamaste-http-redirect (default_server)
│ └── 301 → https://$host$request_uri
│
├── :9292 (IP-based, always available)
│ └── lamalibre-lamaste-panel-ip
│ ├── Self-signed TLS cert
│ ├── mTLS client cert required
│ └── Proxy → 127.0.0.1:3100 (Panel Server)
│
└── :443 (domain-based, after onboarding)
│
├── panel.<domain>
│ └── lamalibre-lamaste-panel-domain
│ ├── Let's Encrypt TLS cert
│ ├── mTLS client cert required
│ └── Proxy → 127.0.0.1:3100 (Panel Server)
│
├── auth.<domain>
│ └── lamalibre-lamaste-auth
│ ├── Let's Encrypt TLS cert
│ └── Proxy → 127.0.0.1:9091 (Authelia)
│
├── tunnel.<domain>
│ └── lamalibre-lamaste-tunnel
│ ├── Let's Encrypt TLS cert
│ ├── WebSocket upgrade
│ └── Proxy → 127.0.0.1:9090 (Chisel Server)
│
└── <app>.<domain>
└── lamalibre-lamaste-app-<name>
├── Let's Encrypt TLS cert
├── Gatekeeper/Authelia auth (unless the tunnel is public)
├── client_max_body_size (per tunnel)
├── Proxy headers per location; X-SSL-Client-* cleared
├── WebSocket upgrade
└── Proxy → 127.0.0.1:<port> (Chisel → carrying agent)Vhost Types
Lamaste manages nine categories of nginx vhosts. Each is generated programmatically by the Panel Server's nginx.js library or the installer's nginx.js task.
1. IP Panel Vhost (lamalibre-lamaste-panel-ip)
Created by the installer. The fallback admin access path that always works.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 9292 ssl;
server_name _;
ssl_certificate /etc/lamalibre/lamaste/pki/self-signed.pem;
ssl_certificate_key /etc/lamalibre/lamaste/pki/self-signed-key.pem;
include /etc/nginx/snippets/lamalibre-lamaste-mtls.conf;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
# Certificate help page for visitors without client cert
error_page 495 496 /cert-help.html;
location = /cert-help.html {
root /opt/lamalibre/lamaste/lamaste-server-ui;
internal;
}
# Proxy to lamalibre-lamaste-serverd
location / {
proxy_pass http://127.0.0.1:3100;
# Client cert headers — set from nginx TLS variables, never passed through from client
proxy_set_header X-SSL-Client-Verify $ssl_client_verify;
proxy_set_header X-SSL-Client-DN $ssl_client_s_dn;
proxy_set_header X-SSL-Client-Serial $ssl_client_serial;
# Standard proxy headers
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;
}
# API paths with WebSocket upgrade support
location /api {
proxy_pass http://127.0.0.1:3100;
proxy_http_version 1.1;
# Client cert headers — set from nginx TLS variables, never passed through from client
proxy_set_header X-SSL-Client-Verify $ssl_client_verify;
proxy_set_header X-SSL-Client-DN $ssl_client_s_dn;
proxy_set_header X-SSL-Client-Serial $ssl_client_serial;
# Standard proxy headers
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;
# WebSocket: only upgrade when client requests it
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}Key characteristics:
- Listens on port 9292, separate from standard HTTPS port 443
- Uses a self-signed TLS certificate (browser will warn, this is expected)
- Includes the mTLS snippet for client certificate enforcement
- Error pages 495 (no cert) and 496 (bad cert) serve a static help page with import instructions
- Two
locationblocks:/for standard HTTP proxy,/apifor WebSocket-capable proxy
2. Domain Panel Vhost (lamalibre-lamaste-panel-domain)
Created during onboarding provisioning. Domain-based panel access with proper TLS.
server {
listen 443 ssl;
server_name panel.example.com;
ssl_certificate /etc/letsencrypt/live/panel.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/panel.example.com/privkey.pem;
# mTLS — same as IP-based access
include /etc/nginx/snippets/lamalibre-lamaste-mtls.conf;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
location / {
proxy_pass http://127.0.0.1:3100;
# Client cert headers — set from nginx TLS variables, never passed through from client
proxy_set_header X-SSL-Client-Verify $ssl_client_verify;
proxy_set_header X-SSL-Client-DN $ssl_client_s_dn;
proxy_set_header X-SSL-Client-Serial $ssl_client_serial;
# Standard proxy headers
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;
}
# API paths with WebSocket upgrade support
location /api {
proxy_pass http://127.0.0.1:3100;
proxy_http_version 1.1;
# Client cert headers — set from nginx TLS variables, never passed through from client
proxy_set_header X-SSL-Client-Verify $ssl_client_verify;
proxy_set_header X-SSL-Client-DN $ssl_client_s_dn;
proxy_set_header X-SSL-Client-Serial $ssl_client_serial;
# Standard proxy headers
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;
# WebSocket: only upgrade when client requests it
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}Identical to the IP vhost except:
- Listens on port 443 (standard HTTPS)
- Uses Let's Encrypt certificate (no browser warning)
server_nameispanel.<domain>- Requires the
map $http_upgrade $connection_upgradeblock from the IP vhost (or a shared config) to be present
3. Auth Vhost (lamalibre-lamaste-auth)
Created during onboarding provisioning. Serves the Authelia authentication portal.
server {
listen 443 ssl;
server_name auth.example.com;
ssl_certificate /etc/letsencrypt/live/auth.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/auth.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
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;
# Invitation page — static HTML
location /invite/ {
alias /var/www/lamaste/invite/;
try_files $uri /invite/index.html;
}
# Invitation API — proxied to panel server (no mTLS)
location /api/invite/ {
proxy_pass http://127.0.0.1:3100/api/invite/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Default — proxy to Authelia
location / {
proxy_pass http://127.0.0.1:9091;
}
}No mTLS requirement — end users access this to authenticate. Protected by Authelia's own login form and TOTP. The auth vhost also serves the invitation page (static HTML at /invite/) and proxies invitation API requests (/api/invite/) to the panel server, allowing invited users to set up their accounts without needing mTLS credentials.
4. Tunnel Vhost (lamalibre-lamaste-tunnel)
Created during onboarding provisioning. WebSocket endpoint for Chisel client connections.
server {
listen 443 ssl;
server_name tunnel.example.com;
ssl_certificate /etc/letsencrypt/live/tunnel.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/tunnel.example.com/privkey.pem;
# ... standard SSL and proxy headers
location / {
proxy_pass http://127.0.0.1:9090;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Long timeout for persistent tunnel connections
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
}Key characteristics:
- WebSocket upgrade is mandatory (Chisel uses WebSocket transport)
- 24-hour read/send timeouts to keep tunnel connections alive without nginx closing them
- No mTLS and no Authelia — Chisel handles its own authentication
5. App Vhost (lamalibre-lamaste-app-<name>)
Created when a tunnel is added via the management UI or POST /api/tunnels. Each tunnel gets its own vhost, rendered by one of three writers according to the tunnel's accessMode:
accessMode | Writer | Authorization | Section |
|---|---|---|---|
restricted (default) | writeRestrictedVhost() | Gatekeeper: Authelia session and a grant | 9 |
authenticated | writeAuthenticatedVhost() | Gatekeeper: any Authelia session | 8 |
public | writePublicVhost() | None | 7 |
All three proxy to 127.0.0.1:<port> — the port Chisel binds on the server for the tunnel's carrying agent — with WebSocket upgrade and 24-hour timeouts, and all three emit the tunnel's request body limit:
client_max_body_size 10m; # from the tunnel's maxBodySizeMb (1–10240, default 10)All three also declare the same request headers inside their location / (TUNNEL_PROXY_HEADERS in nginx.js):
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;
# Never forward client-supplied certificate headers
proxy_set_header X-SSL-Client-Verify "";
proxy_set_header X-SSL-Client-DN "";
proxy_set_header X-SSL-Client-Serial "";
# WebSocket support
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";- Per location, not per server. nginx inherits
proxy_set_headerfrom the enclosing level only when a level declares none of its own. Every tunnel location declares some (Upgrade,Remote-*), so headers placed at server level never reached the app — it sawHost: 127.0.0.1:<port>and no client address. Declared in the location,Host,X-Real-IP,X-Forwarded-ForandX-Forwarded-Protonow reach the app. - Client-certificate headers are always cleared. The panel treats
X-SSL-Client-*from nginx as proof of an mTLS client certificate. A tunnel vhost never passes client-supplied values through, whatever listens on its port. - Identity headers. Public vhosts also clear
Remote-User,Remote-Groups,Remote-NameandRemote-Email— a public tunnel has no authenticated user, and an app might trust those headers from an authenticated vhost. Authenticated and restricted vhosts set them from the Gatekeeper's answer, which replaces anything the client sent. - Relay assertions on plugin tunnels. A plugin tunnel (a vhost with a
pathPrefix) ends at the agent's lamaste-agentd, which takes the user only from a relay assertion — its port can be reached without nginx, so aRemote-Userheader proves nothing to it. Authenticated and restricted plugin vhosts forward the one Gatekeeper signs for the request (auth_request_set $relay_assertion $upstream_http_x_lamalibre_relay_assertion;andproxy_set_header X-Lamalibre-Relay-Assertion $relay_assertion;); public plugin vhosts clear the header. App tunnel vhosts are unchanged. See Security Model.
A tunnel created before maxBodySizeMb existed has no value and its vhost has no directive, so nginx applies its built-in 1 MiB limit until a body limit is set on it (PATCH /api/tunnels/:id with maxBodySizeMb, or Edit in the panel). Changing the access mode or body limit re-renders the vhost in place through the write-with-rollback pattern below; a disabled tunnel's vhost stays disabled.
Agent panel tunnels (type: 'panel') use a separate mTLS vhost (writeAgentPanelVhost()) with no body limit setting. Every request on it is authorized by the panel, which signs the relay assertion the agent's lamaste-agentd requires:
# Authorization by the panel: refuses revoked and foreign certificates,
# otherwise answers with the relay assertion for this one request
location = /internal/lamaste/agent-panel-authz {
internal;
proxy_pass http://127.0.0.1:3100/api/agent-panel/authz/<label>;
proxy_pass_request_body off;
proxy_set_header X-SSL-Client-Verify $ssl_client_verify;
proxy_set_header X-SSL-Client-DN $ssl_client_s_dn;
proxy_set_header X-SSL-Client-Serial $ssl_client_serial;
proxy_set_header X-Original-Method $request_method;
proxy_set_header X-Original-URI $request_uri;
proxy_set_header X-Forwarded-For $remote_addr;
# ... Cookie, Authorization and Remote-* cleared
}
location / {
if ($ssl_client_verify != SUCCESS) { return 496; }
auth_request /internal/lamaste/agent-panel-authz;
auth_request_set $relay_assertion $upstream_http_x_lamalibre_relay_assertion;
proxy_pass http://127.0.0.1:<port>;
proxy_set_header X-Lamalibre-Relay-Assertion $relay_assertion;
# X-SSL-Client-* are still sent for agents older than 4.3; lamaste-agentd 4.3 removes them unread
# ...
}location / has no rewrite: the daemon must receive exactly the $request_uri the assertion names. The panel admits the admin certificate and the agent's own (403 otherwise, also when the agent has no enabled panel tunnel); the panel's 2FA does not apply to this subrequest.
6. Static Site Vhost (lamalibre-lamaste-site-<id>)
Created when a static site is added via the management UI. Serves files directly from disk.
server {
listen 443 ssl;
server_name blog.example.com;
ssl_certificate /etc/letsencrypt/live/blog.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/blog.example.com/privkey.pem;
root /var/www/lamaste/<site-id>/;
index index.html;
# Security headers
add_header X-Frame-Options SAMEORIGIN always;
add_header X-Content-Type-Options nosniff always;
# Optional Authelia forward auth (auth_request to 127.0.0.1:9091/api/authz/auth-request,
# X-Original-URL https://blog.example.com$request_uri — the site's own hostname)
location / {
try_files $uri $uri/ =404; # Standard mode
# OR
try_files $uri $uri/ /index.html; # SPA mode
}
}
# Only for a custom-domain site with aliases — same certificate lineage
server {
listen 443 ssl;
server_name www.myblog.net;
ssl_certificate /etc/letsencrypt/live/myblog.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/myblog.net/privkey.pem;
return 301 https://myblog.net$request_uri;
}Two serving modes based on the spaMode flag:
- Standard:
try_files $uri $uri/ =404— serves files as-is, 404 for missing paths - SPA:
try_files $uri $uri/ /index.html— falls back toindex.htmlfor client-side routing
Optional Authelia protection is controlled by the autheliaProtected flag on the site object. Authelia's rules for a site restricted to some users (allowedUsers) are keyed by the site's hostname, so the subrequest names that hostname, never the client's Host header (see below).
A custom-domain site may list aliases (e.g. www.myblog.net). They are served by a second server block that answers with a 301 to the primary host. One certificate lineage, named after the site's domain (certbot certonly --nginx --cert-name <fqdn> -d <fqdn> -d <alias> ...), covers the primary name and every alias, so both blocks point at the same live/<fqdn>/ directory.
7. Public Tunnel Vhost (lamalibre-lamaste-app-<name>, access mode: public)
Created when a tunnel is configured with public access. Direct proxy with no authentication.
server {
listen 443 ssl;
server_name public-app.example.com;
ssl_certificate /etc/letsencrypt/live/public-app.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/public-app.example.com/privkey.pem;
# ... standard SSL settings
client_max_body_size 10m;
location / {
# A public tunnel has no authenticated user: clear identity headers
# an app might trust from an authenticated vhost.
proxy_set_header Remote-User "";
proxy_set_header Remote-Groups "";
proxy_set_header Remote-Name "";
proxy_set_header Remote-Email "";
# ... the tunnel proxy headers (section 5): Host, X-Real-IP,
# X-Forwarded-For/Proto, X-SSL-Client-* cleared, WebSocket
proxy_pass http://127.0.0.1:<port>;
proxy_http_version 1.1;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
}No auth_request, no Authelia, no Gatekeeper. Traffic is proxied directly to the tunneled app. Use only for services that handle their own authentication or are intentionally public.
8. Authenticated Tunnel Vhost (lamalibre-lamaste-app-<name>, access mode: authenticated)
Created when a tunnel requires Authelia login but no per-user grant checking. Any authenticated Authelia user can access the tunnel.
server {
listen 443 ssl;
server_name app.example.com;
ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;
# ... standard SSL settings
client_max_body_size 10m;
# Gatekeeper authorization subrequest (handles Authelia validation + grant check).
# The tunnel is named in the path — never by the client's Host header.
location /internal/lamaste/authz {
internal;
proxy_pass http://127.0.0.1:9294/authz/tunnel/app.example.com;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header Connection "";
proxy_set_header X-Original-Method $request_method;
proxy_set_header X-Original-URL https://app.example.com$request_uri;
proxy_set_header X-Forwarded-For $remote_addr;
# ... X-Forwarded-Host, -Proto, -Uri and -Method cleared ("")
proxy_set_header Host app.example.com;
proxy_set_header Cookie $http_cookie;
proxy_http_version 1.1;
proxy_buffers 4 32k;
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
}
location / {
auth_request /internal/lamaste/authz;
auth_request_set $user $upstream_http_remote_user;
auth_request_set $groups $upstream_http_remote_groups;
auth_request_set $name $upstream_http_remote_name;
auth_request_set $email $upstream_http_remote_email;
# Identity comes from the Gatekeeper's answer, replacing anything
# the client sent under these names
proxy_set_header Remote-User $user;
proxy_set_header Remote-Groups $groups;
proxy_set_header Remote-Name $name;
proxy_set_header Remote-Email $email;
# ... the tunnel proxy headers (section 5): Host, X-Real-IP,
# X-Forwarded-For/Proto, X-SSL-Client-* cleared, WebSocket
proxy_pass http://127.0.0.1:<port>;
proxy_http_version 1.1;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
# Authentication failure -> redirect to Authelia login, back to this vhost
error_page 401 =302 https://auth.<domain>/?rd=https://app.example.com$request_uri;
}
}Key characteristics:
auth_requesttargets the Gatekeeper at127.0.0.1:9294/authz/tunnel/<fqdn>instead of Authelia directly- The subrequest names the tunnel by the vhost's own hostname, written into the configuration — in its path,
X-Original-URLandHost— never by the client'sHostheader (see below) - The Gatekeeper validates the Authelia session cookie and returns 200 (authenticated) or 401 (unauthenticated)
- The upstream receives
Remote-*headers only as set from the Gatekeeper's response; client-supplied values under those names are replaced - 401 redirects to the Authelia login portal
Note: The
lamalibre-lamaste-authz-cache.confnginx snippet is installed by the Gatekeeper installer task, but the generated vhosts do not currently includeproxy_cachedirectives. Authorization caching relies on the Gatekeeper's in-memory session cache (30-second TTL) rather than nginx-level proxy caching.
9. Restricted Tunnel Vhost (lamalibre-lamaste-app-<name>, access mode: restricted)
Created when a tunnel requires both Authelia login and an explicit access grant. Only users with a matching grant in access-grants.json can access the tunnel.
server {
listen 443 ssl;
server_name restricted-app.example.com;
ssl_certificate /etc/letsencrypt/live/restricted-app.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/restricted-app.example.com/privkey.pem;
# ... standard SSL settings
client_max_body_size 10m;
# Gatekeeper authorization subrequest (handles Authelia validation + grant check).
# The tunnel is named in the path — never by the client's Host header.
location /internal/lamaste/authz {
internal;
proxy_pass http://127.0.0.1:9294/authz/tunnel/restricted-app.example.com;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header Connection "";
proxy_set_header X-Original-Method $request_method;
proxy_set_header X-Original-URL https://restricted-app.example.com$request_uri;
proxy_set_header X-Forwarded-For $remote_addr;
# ... X-Forwarded-Host, -Proto, -Uri and -Method cleared ("")
proxy_set_header Host restricted-app.example.com;
proxy_set_header Cookie $http_cookie;
proxy_http_version 1.1;
proxy_buffers 4 32k;
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
}
location / {
auth_request /internal/lamaste/authz;
auth_request_set $user $upstream_http_remote_user;
auth_request_set $groups $upstream_http_remote_groups;
auth_request_set $name $upstream_http_remote_name;
auth_request_set $email $upstream_http_remote_email;
# Identity comes from the Gatekeeper's answer, replacing anything
# the client sent under these names
proxy_set_header Remote-User $user;
proxy_set_header Remote-Groups $groups;
proxy_set_header Remote-Name $name;
proxy_set_header Remote-Email $email;
# ... the tunnel proxy headers (section 5): Host, X-Real-IP,
# X-Forwarded-For/Proto, X-SSL-Client-* cleared, WebSocket
proxy_pass http://127.0.0.1:<port>;
proxy_http_version 1.1;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
# Authentication failure -> redirect to Authelia login, back to this vhost
error_page 401 =302 https://auth.<domain>/?rd=https://restricted-app.example.com$request_uri;
# Authorization failure -> Gatekeeper serves inline access-request page
error_page 403 = /internal/lamaste/authz;
}
}Key differences from the authenticated vhost:
- The Gatekeeper checks both the Authelia session and the access grant. If the user is authenticated but has no grant for this resource, the Gatekeeper returns 403 instead of 200
error_page 403 = /internal/lamaste/authzreplays the request to the Gatekeeper internal location. The=prefix causes nginx to use the response body and status code from the Gatekeeper, which serves an inline HTML access-request page with pre-filled message templates- 401 (unauthenticated) still redirects to Authelia login as before
Authorization subrequests name their own hostname
Every authorization subrequest — to Gatekeeper from an authenticated or restricted tunnel vhost (app and plugin tunnels), to Authelia from a protected static site and from the auth portal's /api/user-access/authorize — names the vhost by its own hostname, written into the configuration: X-Original-URL https://<fqdn>$request_uri; for Gatekeeper also the path, /authz/tunnel/<fqdn>, and Host <fqdn>. A tunnel's 401 login redirect returns to https://<fqdn>$request_uri. The client's X-Forwarded-Host, -Proto, -Uri and -Method are cleared in these subrequests. Generated vhosts contain no $http_host.
nginx selects the vhost from the request line when a request is in absolute form (GET https://a.example.com/x HTTP/1.1) and from Host otherwise, so a client can send the request line of one hostname with the Host header of another. Vhosts written before 4.3 built X-Original-URL and Host from $http_host — the client's Host header — so the subrequest asked about the other hostname while nginx served this one: an Authelia user allowed on any tunnel (every user, on an authenticated tunnel) reached any restricted tunnel, app or plugin, without a grant, and any Authelia user reached a static site restricted to other users. $host would not fix it — on the default server it is whatever Host the client sent. $request_uri is what follows the host in the request target — it starts with /, or with ? for an absolute-form target without a path — so it cannot change the URL's host; the scheme is fixed because every such vhost listens on 443 with TLS only. The generators refuse a hostname that is not a plain DNS name, so nothing but a literal reaches these headers. For the auth portal the change is hardening: no rule Lamaste writes for Authelia is more permissive than the portal's own (the default, two_factor), so naming another hostname there gained nothing.
Gatekeeper authorizes the tunnel named in the path, and refuses a check whose X-Original-URL or Host names another hostname, or has no Host (401). It refuses /authz/check — what vhosts written before 4.3 call, with the client's Host in both headers — outright (403, logged as vhost_predates_host_binding): a tunnel vhost the panel has not rewritten is closed, not open to the users of other tunnels. The panel rewrites them at startup (see Rewritten at Startup).
Authelia evaluates the raw $request_uri, while nginx serves the normalized $uri (/a/../b is /b, %2e is .). Lamaste writes no path (resources) rules into Authelia's access_control, only hostname rules; a path rule added by hand would see a different path than nginx serves.
nginx Cache Zone (lamaste_authz)
The Gatekeeper authorization responses are cached by nginx to reduce subrequest overhead. The cache zone is defined in a shared snippet:
File: /etc/nginx/snippets/lamalibre-lamaste-authz-cache.conf
proxy_cache_path /var/cache/nginx/authz levels=1:2 keys_zone=lamaste_authz:1m max_size=10m inactive=5m;| Parameter | Value | Purpose |
|---|---|---|
keys_zone | 1m | 1 MB for cache keys (sufficient for thousands of entries) |
max_size | 10m | 10 MB maximum disk storage for cached responses |
inactive | 5m | Evict entries not accessed within 5 minutes |
The installer also writes the location-level policy, /etc/nginx/snippets/lamalibre-lamaste-authz-cache-loc.conf:
proxy_cache lamaste_authz;
proxy_cache_key "$server_name|$cookie_authelia_session";
proxy_cache_valid 200 10s;
proxy_cache_valid 403 0;
proxy_cache_use_stale off;Cache key: $server_name|$cookie_authelia_session — one entry per vhost and user session. $server_name is the vhost's own name, first and followed by a character no hostname contains, so no other vhost and session give the same key. $http_host (in the key before 4.3) is the client's Host header, which can name another tunnel than the vhost serving the request (see above), so a decision cached for that other tunnel would have been replayed. A relay installed before 4.3 keeps the old snippet until it is reinstalled; no vhost includes it.
Cache TTLs (when used): 200 (authorized) for 10 seconds, matching the Cache-Control: max-age=10 Gatekeeper sends; 403 never, so a new grant takes effect at once. Gatekeeper marks a plugin tunnel's answer no-store — it carries a single-use relay assertion.
Note: The cache zone is installed by the Gatekeeper installer task, but the generated vhosts (
buildGatekeeperVhost()) do not currently includeproxy_cachedirectives in the internal location block. Authorization caching currently relies on the Gatekeeper's in-memory session cache (30-second TTL). The nginx cache zone is available for future use if nginx-level caching is needed.
mTLS Snippet Pattern
The mTLS configuration is shared across panel vhosts via an nginx snippet:
File: /etc/nginx/snippets/lamalibre-lamaste-mtls.conf
ssl_client_certificate /etc/lamalibre/lamaste/pki/ca.crt;
ssl_verify_client optional;Included with include /etc/nginx/snippets/lamalibre-lamaste-mtls.conf; in any vhost that requires client certificate verification.
What ssl_verify_client optional does:
- During the TLS handshake, nginx requests a client certificate from the browser
- The browser presents the imported
client.p12certificate (or sends no certificate) - nginx verifies the certificate was signed by the CA at
/etc/lamalibre/lamaste/pki/ca.crt - If verification succeeds:
$ssl_client_verifyis set toSUCCESS - If no certificate is sent or verification fails: the TLS handshake still completes, but
$ssl_client_verifyis notSUCCESS
Location-level mTLS enforcement:
Protected locations (/, /api) enforce mTLS explicitly:
if ($ssl_client_verify != SUCCESS) { return 496; }Public locations (/api/enroll, /api/invite) skip the mTLS check and clear cert headers to prevent spoofing:
location /api/enroll {
proxy_set_header X-SSL-Client-Verify "";
proxy_set_header X-SSL-Client-DN "";
proxy_set_header X-SSL-Client-Serial "";
# ... proxy_pass to lamalibre-lamaste-serverd
limit_req zone=enroll burst=5 nodelay;
}The /api/enroll endpoint is rate-limited (limit_req zone=enroll burst=5 nodelay) to prevent brute-force token attempts. The enroll zone is defined in the http block.
This design allows the panel vhosts to serve both mTLS-protected and public endpoints on the same port, while keeping the security boundary at the location level.
Write-With-Rollback Pattern
All vhost modifications in the Panel Server follow a safe write-with-rollback sequence, implemented once in safeWriteVhost() in packages/server/daemon/src/lib/nginx.js and used by tunnel, static site, agent panel and core vhosts. The panel cannot write /etc/nginx itself: every file operation is a call to the root-owned helper sudo /usr/local/sbin/lamaste-priv nginx-site <action> <name>, with the vhost text on stdin.
┌──────────────────────────────────────────────────────┐
│ 1. Backup existing vhost (if any) │
│ lamaste-priv nginx-site backup <name> │
├──────────────────────────────────────────────────────┤
│ 2. Install the new vhost (text on stdin) │
│ lamaste-priv nginx-site write <name> │
│ → validated against the allow-list, then written │
│ atomically to sites-available/<name> (0644) │
├──────────────────────────────────────────────────────┤
│ 3. Enable (or disable) the site │
│ lamaste-priv nginx-site enable|disable <name> │
├──────────────────────────────────────────────────────┤
│ 4. Test configuration │
│ sudo nginx -t │
│ ├── Success → continue to step 5 │
│ └── Failure → rollback │
│ If backup exists: nginx-site restore <name> │
│ If no backup: nginx-site remove <name> │
│ Restore the previous enabled/disabled state │
│ Throw error with nginx -t output │
├──────────────────────────────────────────────────────┤
│ 5. Reload nginx │
│ sudo systemctl reload nginx │
├──────────────────────────────────────────────────────┤
│ 6. Clean up backup │
│ lamaste-priv nginx-site discard-backup <name> │
└──────────────────────────────────────────────────────┘Why this matters: A bad vhost configuration can prevent nginx from reloading, breaking all sites on the server. The nginx -t test catches syntax errors and missing certificate files before the reload. The rollback ensures the previous working configuration — file and link state — is restored if the test fails. Any other error (including a vhost lamaste-priv refuses) triggers the same rollback.
Removing a vhost is lamaste-priv nginx-site remove <name> (the file, its sites-enabled link and its backup).
Vhost allow-list
nginx's master process runs as root and opens the files a configuration names — access_log /etc/cron.d/x with a crafted log_format would be a root shell for whoever controls the panel. lamaste-priv therefore installs a vhost only if:
- its name is one the panel manages:
lamalibre-lamaste-panel-domain,-auth,-tunnel,-app-<subdomain>,-agent-panel-<label>or-site-<uuid>.lamalibre-lamaste-panel-ip(written by the installer) may only be enabled or disabled — which is how panel 2FA turns the IP vhost off; - every directive is on the allow-list for its context, with arguments that cannot name a file outside what Lamaste serves:
| Context | Allowed |
|---|---|
| top level | server, map (plain key value; entries, no include), limit_req_zone |
server | listen 443 / [::]:443 with optional ssl / http2; server_name (hostnames); ssl_certificate(_key) only /etc/letsencrypt/live/<name>/{fullchain,privkey}.pem; include only /etc/nginx/snippets/lamalibre-lamaste-*.conf; root only under /var/www/lamaste/; ssl_protocols, ssl_ciphers, ssl_prefer_server_ciphers, client_max_body_size, index, add_header, proxy_set_header, error_page, return, rewrite, location |
location | proxy_pass only http://127.0.0.1:<port>[/path]; root / alias only under /var/www/lamaste/; proxy_set_header, proxy_http_version, proxy_read_timeout, proxy_send_timeout, proxy_buffers, proxy_next_upstream, proxy_pass_request_body, auth_request, auth_request_set, limit_req, try_files, index, add_header, error_page, client_max_body_size, internal, return, rewrite, if, nested location |
if | return, rewrite |
Port 80 is not allowed: it belongs to the installer's HTTPS redirect and to certbot's HTTP-01 challenges. Backslashes, ${, control characters and non-ASCII text outside comments are refused rather than interpreted, as is any directive not listed — including ones nginx would accept (access_log, log_format, ssl_* files elsewhere, proxy_pass to another host, ...). A refused write fails with nginx site write failed for <name>: lamaste-priv: vhost line <n>: ... and leaves the previous vhost in place.
Adding a directive to a generator therefore means adding it to the allow-list in packages/provisioners/server/scripts/lamaste-priv too. npm test checks both sides: packages/server/daemon/test/nginx-vhosts.test.js runs every generator's output through the allow-list, and packages/provisioners/server/test/lamaste-priv.test.js covers the refusals.
Forward Auth Block
The Authelia forward auth integration uses nginx's auth_request module. Protected static sites call Authelia directly as shown below; restricted and authenticated tunnels send the same subrequest to Gatekeeper (127.0.0.1:9294/authz/tunnel/<fqdn>), which validates the Authelia session and, for restricted tunnels, the grant (sections 8 and 9). Here is the flow:
1. User requests https://myapp.example.com/page
│
2. nginx sends internal subrequest to /internal/lamaste/authz
│ ├── proxy_pass → http://127.0.0.1:9091/api/authz/auth-request
│ ├── Sends X-Original-URL (https://myapp.example.com/page — the vhost's
│ │ own hostname, never the client's Host header), X-Original-Method
│ └── Strips request body (Content-Length: "")
│
3. Authelia checks the session cookie
│ ├── Valid session → 200 + user info headers
│ └── No session → 401 + Location header with redirect URL
│
4. nginx processes auth_request result
│ ├── 200 → Extract user info headers (auth_request_set)
│ │ Set Remote-User, Remote-Groups, etc.
│ │ Proxy to tunneled app at 127.0.0.1:<port>
│ │
│ └── 401 → auth_request_set captures $redirection_url from Location header
│ error_page 401 =302 $redirection_url
│ Redirect to Authelia login with return URL
│
5. After Authelia login (TOTP verified):
│ Authelia sets session cookie on the domain
│ Redirects back to original URL
│ Step 3 succeeds on retryThe forward auth pattern means the tunneled app never needs to implement authentication. It receives pre-verified user identity via HTTP headers.
WebSocket Upgrade Support
WebSocket support requires specific nginx configuration:
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";These directives are applied in:
- Panel vhosts (
/apilocation) — for provisioning progress stream and live log streaming - Tunnel vhost — for Chisel WebSocket transport
- App vhosts (
/location) — for tunneled apps that use WebSocket
The 24-hour timeout (proxy_read_timeout 86400s) is set on tunnel and app vhosts to prevent nginx from closing long-lived WebSocket connections.
Directory Structure
/etc/nginx/
├── nginx.conf ← Default nginx config (not modified by Lamaste)
├── sites-available/
│ ├── lamalibre-lamaste-http-redirect ← :80 catch-all → HTTPS (created by installer)
│ ├── lamalibre-lamaste-panel-ip ← IP:9292 panel access (created by installer)
│ ├── lamalibre-lamaste-panel-domain ← panel.<domain> (created during onboarding)
│ ├── lamalibre-lamaste-auth ← auth.<domain> (created during onboarding)
│ ├── lamalibre-lamaste-tunnel ← tunnel.<domain> (created during onboarding)
│ ├── lamalibre-lamaste-app-myapp ← <app>.<domain> (created per tunnel)
│ └── lamalibre-lamaste-site-<uuid> ← <site>.<domain> (created per static site)
├── sites-enabled/
│ ├── lamalibre-lamaste-http-redirect → ../sites-available/lamalibre-lamaste-http-redirect
│ ├── lamalibre-lamaste-panel-ip → ../sites-available/lamalibre-lamaste-panel-ip
│ ├── lamalibre-lamaste-panel-domain → ../sites-available/lamalibre-lamaste-panel-domain
│ ├── lamalibre-lamaste-auth → ../sites-available/lamalibre-lamaste-auth
│ ├── lamalibre-lamaste-tunnel → ../sites-available/lamalibre-lamaste-tunnel
│ ├── lamalibre-lamaste-app-myapp → ../sites-available/lamalibre-lamaste-app-myapp
│ └── lamalibre-lamaste-site-<uuid> → ../sites-available/lamalibre-lamaste-site-<uuid>
└── snippets/
├── lamalibre-lamaste-mtls.conf ← Shared mTLS client cert verification
└── lamalibre-lamaste-authz-cache.conf ← Gatekeeper proxy_cache zone definitionAll Lamaste-managed files are prefixed with lamalibre-lamaste- to distinguish them from other nginx configurations on the system. The installer writes its own files as root; the panel's vhosts (everything created during onboarding and at runtime, including lamalibre-lamaste-agent-panel-<label>) are installed through lamaste-priv.
Vhost Lifecycle
Created by the Installer
| Vhost | When | Removed |
|---|---|---|
lamalibre-lamaste-panel-ip | Installation | Never (fallback access) |
lamalibre-lamaste-http-redirect | Installation, redeploy | Uninstall |
lamalibre-lamaste-http-redirect is the port-80 catch-all:
# Managed by Lamaste — plain HTTP is redirected to HTTPS.
server {
listen 80 default_server;
server_name _;
return 301 https://$host$request_uri;
}Without it nothing listens on port 80 and http:// links to the relay's hostnames are refused. It does not interfere with Let's Encrypt: for an HTTP-01 challenge (certbot certonly --nginx and renewals) certbot's nginx plugin clones this default server block for the name being validated, answers the challenge path ahead of the redirect, and restores the file afterwards. This was verified on Ubuntu 24.04 (nginx 1.24, certbot 2.9) for new names, names that already have a 443 block, multi-name lineages and forced renewals. The installer tests the file with nginx -t and restores the previous state on failure; if another site already owns port 80's default_server, the step is skipped — not fatal — and the relay works without the redirect.
Created During Onboarding
| Vhost | When | Removed |
|---|---|---|
lamalibre-lamaste-panel-domain | Provisioning step 4 | Never (primary panel access) |
lamalibre-lamaste-auth | Provisioning step 4 | Never (auth portal) |
lamalibre-lamaste-tunnel | Provisioning step 4 | Never (tunnel endpoint) |
Created/Removed at Runtime
| Vhost | Created | Removed |
|---|---|---|
lamalibre-lamaste-app-<name> | POST /api/tunnels | DELETE /api/tunnels/:id |
lamalibre-lamaste-site-<uuid> | POST /api/sites | DELETE /api/sites/:id |
PATCH /api/tunnels/:id removes or restores the sites-enabled symlink when a tunnel is disabled or enabled (the file in sites-available is kept), and re-renders the vhost in place when accessMode or maxBodySizeMb changes. PATCH /api/sites/:id re-renders a site vhost when spaMode, autheliaProtected or aliases change.
Rewritten at Startup
A vhost is written when its tunnel or site is created or changed, so a generator change does not reach existing vhosts by itself. At startup, once onboarding is complete, the panel (lib/vhost-reconcile.js) renders installed vhosts again and rewrites each one whose text differs through the write-with-rollback pattern, on the certificate directory the installed file names (a wildcard certificate stays a wildcard certificate). A rewrite never links a vhost that is not linked now; a disabled tunnel's vhost is unlinked:
- Before it listens: the auth portal (
lamalibre-lamaste-auth) and every static site, fromsites.jsonand the domain. Site changes take no lock, so this runs while none can arrive. - Once it listens, under the tunnel lock: every agent-panel, app and plugin tunnel, from
tunnels.json— the stored access mode (restrictedwhen absent, as Gatekeeper reads it), the plugin route, and the body limit only when the tunnel has one. After listen because a rewritten agent-panel vhost asks the panel about every request.
That is how vhosts written before 4.3 start forwarding relay assertions (agent-panel and plugin tunnels) and start naming their own hostname to Gatekeeper and Authelia (see above). A rewrite never adds or removes authentication: a tunnel or site vhost whose auth_request disagrees with its state (a public vhost for a restricted tunnel, say) is logged and left alone until the tunnel or site is reconfigured — the agent-panel vhost, which gains the panel's auth_request beside its mTLS check, is the exception. Missing vhosts are logged (a site still waiting for its certificate has none yet); a failed rewrite restores the previous file and the others still run.
A tunnel vhost left as it was fails closed: Gatekeeper refuses its /authz/check subrequests, so the tunnel answers 403 until it is rewritten. A static site or auth portal vhost left as it was still names the client's Host to Authelia. After an upgrade, check the panel's startup log for Left tunnel vhosts as they are, Left site vhosts as they are and Failed to rewrite … vhost, fix what they name (a failing nginx -t, a missing certificate) and restart lamalibre-lamaste-serverd.
Key Files
| File | Role |
|---|---|
packages/provisioners/server/src/tasks/nginx.js | Installer: self-signed cert, mTLS snippet, IP vhost, port-80 redirect |
packages/provisioners/server/src/lib/http-redirect.js | Installs/refreshes the port-80 redirect site (installer and redeploy) |
packages/server/daemon/src/lib/nginx.js | Runtime: vhost generation, write-with-rollback, enable/disable/reload |
packages/provisioners/server/scripts/lamaste-priv | Root helper (/usr/local/sbin/lamaste-priv): site names, vhost allow-list, install/enable/remove |
/etc/nginx/snippets/lamalibre-lamaste-mtls.conf | Shared mTLS snippet |
/etc/nginx/snippets/lamalibre-lamaste-authz-cache.conf | Gatekeeper proxy_cache zone definition |
/etc/nginx/sites-available/lamalibre-lamaste-* | Vhost configuration files |
/etc/nginx/sites-enabled/lamalibre-lamaste-* | Symlinks to enabled vhosts |
Design Decisions
Why port 9292 for IP-based access?
Port 9292 is above the privileged port range (no root needed to listen) and is unlikely to conflict with other services. It is deliberately different from port 443 to ensure the IP-based fallback always works, even if domain-based HTTPS on 443 is misconfigured.
Why self-signed certificates for IP access?
Let's Encrypt cannot issue certificates for IP addresses. A self-signed certificate provides encryption for the connection, even though the browser shows a warning. The mTLS client certificate is the real security mechanism — it proves the caller's identity regardless of the server certificate.
Why include the mTLS snippet instead of inline?
The mTLS configuration (two lines) is identical across panel vhosts. An nginx snippet avoids duplication and ensures that if the CA certificate path changes, it only needs to be updated in one place. This is especially important because the IP vhost is created by the installer and the domain vhost is created by the Panel Server — they must stay in sync.
Why error pages 495/496 for certificate help?
nginx emits error 495 when no client certificate is provided and 496 when the certificate is invalid. These are nginx-specific error codes (not standard HTTP). By mapping them to a static HTML page with import instructions, visitors who forgot to import their certificate see helpful guidance instead of a cryptic "400 Bad Request".
Why 24-hour WebSocket timeouts?
Chisel tunnel connections and some app WebSocket connections are long-lived. nginx's default proxy_read_timeout of 60 seconds would close these connections, causing tunnel drops. The 24-hour timeout is effectively "never timeout" for practical purposes, while still allowing nginx to reclaim resources from genuinely dead connections.
Why a root helper for nginx operations?
The Panel Server runs as the lamaste user, not root. Writing to /etc/nginx/sites-available/ and reloading nginx require root privileges. Instead of running the Panel Server as root, Lamaste allows the lamaste user a few fixed command lines (nginx -t, systemctl reload nginx, ...) and the root-owned lamaste-priv helper, which accepts only the panel's own site names and only vhosts built from the allow-listed directives above. Earlier versions used sudoers rules with wildcards (mv /tmp/nginx-* /etc/nginx/sites-available/*); a sudoers * also matches spaces, so such a rule accepted extra arguments, and even an exact rule would have let the panel install any nginx configuration — which nginx's root master process would act on. Certbot is reached only through the root-owned /usr/local/sbin/lamaste-certbot wrapper, which validates hostnames and email and runs certbot with a fixed argument vector. See Security Model.