A reverse proxy handles SSL termination, routes requests to the correct backend service, and serves the web frontend. This step shows configuration for both Apache and Nginx — choose whichever you prefer.
Behind a non-loopback proxy? Set [api] trusted_proxies on the node. The node only honours the X-Forwarded-For / X-Real-IP headers below (for per-IP rate limiting and the admin localhost bypass) when the connecting IP is trusted. Loopback (127.0.0.1) is always implicitly trusted, so a same-host Apache/Nginx in front of the node needs no change. But if the node runs in a container while the proxy sits on the host (or the proxy is on another machine), the node sees the request coming from the Docker bridge / proxy IP — not loopback. You must then add that source to [api] trusted_proxies (e.g. the Docker bridge CIDR "172.17.0.0/16"); otherwise the node buckets every client as the proxy IP (rate limits misfire) and the admin localhost bypass won't recognize you. See Configuration.
Looking for the admin-dashboard vhost? The admin dashboard lives on a separate subdomain with its own vhost block. See Admin Dashboard for the full pattern (DNS, certificate, Apache + Nginx templates) — the public-API vhost below intentionally denies /admin/ so the two surfaces never share a host.
Install Certbot with the plugin for your web server. Using the web server plugin (instead of --standalone) is important — it lets Certbot obtain and renew certificates while your web server keeps running.
# For Apache:
sudo apt install -y certbot python3-certbot-apache
# For Nginx:
sudo apt install -y certbot python3-certbot-nginx
Obtain a certificate using your web server plugin. This handles the domain verification challenge through your running web server, so there is no need to stop it.
# For Apache (replace with your domain):
sudo certbot --apache -d node.yourdomain.com
# For Nginx (replace with your domain):
sudo certbot --nginx -d node.yourdomain.com
Do not use certbot certonly --standalone if you already have a web server running. Standalone mode needs to bind port 80 itself, which will fail if Apache or Nginx is already listening on it. This also means renewals will fail later. Always use the --apache or --nginx plugin instead.
Let's Encrypt certificates expire every 90 days. Certbot sets up automatic renewal when installed, but the mechanism depends on how it was installed. Verify that renewal is configured and working.
# On Debian/Ubuntu with apt-installed certbot, renewal runs via cron:
cat /etc/cron.d/certbot
# On systems with snap-installed certbot, renewal runs via systemd timer:
sudo systemctl status certbot.timer
# Test that renewal works (dry run — does not actually renew)
sudo certbot renew --dry-run
If neither the cron job nor the systemd timer exists, create a cron job manually.
# Create a cron job that checks for renewal twice daily
echo '0 0,12 * * * root certbot renew --quiet' | sudo tee /etc/cron.d/certbot
After renewal, your web server needs to reload to pick up the new certificate. Add a deploy hook so this happens automatically.
# For Nginx:
sudo sh -c 'printf "#!/bin/sh\nsystemctl reload nginx\n" > /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh'
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
# For Apache:
sudo sh -c 'printf "#!/bin/sh\nsystemctl reload apache2\n" > /etc/letsencrypt/renewal-hooks/deploy/reload-apache.sh'
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-apache.sh
The dry run (sudo certbot renew --dry-run) must succeed for all your certificates. If any fail, check that each certificate is using the correct authenticator. You can see and fix this in the renewal config files at /etc/letsencrypt/renewal/*.conf — look for the authenticator line and make sure it says apache or nginx, not standalone.
If you already obtained a certificate using --standalone and renewals are failing because port 80 is in use, you can switch the authenticator without re-issuing the certificate.
# Edit the renewal config for the affected domain
sudo nano /etc/letsencrypt/renewal/node.yourdomain.com.conf
# Change this line:
# authenticator = standalone
# To:
# authenticator = apache (or nginx)
# Then test:
sudo certbot renew --dry-run
-le-ssl.conf shadow vhostWhen you run sudo certbot --apache -d node.yourdomain.com against an existing HTTP-only vhost, Certbot auto-creates a parallel SSL companion file at /etc/apache2/sites-enabled/<your-vhost-name>-le-ssl.conf containing only the cert paths and SSL include — none of your ProxyPass rules. If your original vhost already has SSL set up (per the Apache template below), you now have two <VirtualHost *:443> blocks claiming the same ServerName. Apache picks the one loaded first by filename sort, and -le-ssl.conf alphabetically beats ogmara-node.conf (- comes before . in ASCII), so the stub wins and your API endpoints return Apache's default 404 page instead of proxying to the L2 node.
Detect this conflict:
# Two namevhost lines for the same domain+port = duplicate vhost
sudo apache2ctl -S 2>&1 | grep "namevhost $YOUR_DOMAIN"
# If you see two lines for port 443, the shadow is active.
# Compare what each file contains:
sudo cat /etc/apache2/sites-enabled/*-le-ssl.conf
sudo cat /etc/apache2/sites-enabled/<your-vhost>.conf
Fix: disable the auto-generated shadow stub and keep your full vhost as the canonical one. Renewals will continue to work — certbot renew only refreshes the cert PEM files, it doesn't re-create vhost files, and your full vhost already references the same /etc/letsencrypt/live/<domain>/ paths Certbot updates.
# Disable the auto-generated SSL companion
sudo a2dissite <your-vhost>-le-ssl
# Verify only your real vhost is now active (one namevhost line per port)
sudo apache2ctl configtest
sudo apache2ctl -S 2>&1 | grep "namevhost $YOUR_DOMAIN"
# Reload to apply
sudo systemctl reload apache2
# Confirm the API now returns JSON (not Apache's default HTML 404)
curl -s https://node.yourdomain.com/api/v1/health
Symptom of a missed shadow vhost: curl https://node.yourdomain.com/api/v1/health returns the default Apache HTML Not Found / The requested URL was not found on this server. page (note: HTML, not JSON), but docker exec ogmara-l2 curl http://127.0.0.1:41721/api/v1/health returns the expected JSON. That divergence means the L2 node is fine, Apache just isn't reaching it — almost always the shadow-vhost trap.
# /etc/nginx/sites-available/ogmara
upstream ogmara_api {
server 127.0.0.1:41721;
}
# NOTE: the push gateway is NOT proxied here. It runs on its own
# subdomain (e.g. push.yourdomain.com) because device registrations are
# cryptographically bound to its public_url — a path under this host
# (/push/) would break that binding. See the Push Gateway page for its
# own vhost: /tutorials/node/push-gateway.html
# Redirect HTTP to HTTPS
server {
listen 80;
server_name node.yourdomain.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name node.yourdomain.com;
ssl_certificate /etc/letsencrypt/live/node.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/node.yourdomain.com/privkey.pem;
# Security headers
add_header X-Content-Type-Options nosniff;
add_header X-Frame-Options DENY;
add_header Referrer-Policy strict-origin-when-cross-origin;
# L2 Node API
location /api/ {
proxy_pass http://ogmara_api;
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 endpoint
location /api/v1/ws {
proxy_pass http://ogmara_api;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
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_read_timeout 86400;
}
# Block admin endpoints from public access
location /admin/ {
deny all;
return 403;
}
# Web frontend (SPA)
location /app/ {
alias /var/www/ogmara/app/;
try_files $uri $uri/ /app/index.html;
}
# Root redirect
location = / {
return 302 /app/;
}
}
# Enable the site and reload
sudo ln -s /etc/nginx/sites-available/ogmara /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
# /etc/apache2/sites-available/ogmara.conf
<VirtualHost *:80>
ServerName node.yourdomain.com
Redirect permanent / https://node.yourdomain.com/
</VirtualHost>
<VirtualHost *:443>
ServerName node.yourdomain.com
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/node.yourdomain.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/node.yourdomain.com/privkey.pem
# Security headers
Header always set X-Content-Type-Options nosniff
Header always set X-Frame-Options DENY
Header always set Referrer-Policy strict-origin-when-cross-origin
# WebSocket upgrade — MUST come BEFORE the generic /api/ proxy, and the WS
# path MUST be excluded from it (ProxyPass /api/v1/ws !). Otherwise the
# ProxyPass below matches /api/v1/ws first and forwards it as plain HTTP, the
# WebSocket handshake fails, and WS-dependent features (live DM/notification
# delivery) silently break.
RewriteEngine On
RewriteCond %{HTTP:Upgrade} =websocket [NC]
RewriteCond %{HTTP:Connection} upgrade [NC]
RewriteRule ^/api/v1/ws(.*)$ ws://127.0.0.1:41721/api/v1/ws$1 [P,L]
# L2 Node API
ProxyPass /api/v1/ws !
ProxyPass /api/ http://127.0.0.1:41721/api/
ProxyPassReverse /api/ http://127.0.0.1:41721/api/
# NOTE: the push gateway is NOT proxied here — it runs on its own
# subdomain (its device registrations are cryptographically bound to
# public_url). See /tutorials/node/push-gateway.html for its vhost.
# Block admin endpoints
<Location /admin/>
Require all denied
</Location>
# Web frontend (SPA)
Alias /app/ /var/www/ogmara/app/
<Directory /var/www/ogmara/app/>
Require all granted
FallbackResource /app/index.html
</Directory>
# Root redirect
RedirectMatch ^/$ /app/
</VirtualHost>
# Enable required modules and site
sudo a2enmod ssl proxy proxy_http proxy_wstunnel rewrite headers
sudo a2ensite ogmara
sudo apache2ctl configtest
sudo systemctl reload apache2
Security: The /admin/ path must be blocked from public access on the public-API subdomain. The admin endpoints provide node management capabilities that should never be exposed alongside the public API. The configurations above deny all access to /admin/ on the public host; the Admin Dashboard page covers the recommended pattern of putting the admin dashboard on a separate subdomain with its own vhost.