Skip to content

TLS / reverse proxy

Three paths.

Path 1 — Self-signed (default)

Without a certificate of your own, the nginx container generates a self-signed pair on first start (10 years, CN = host from BASE_URL) in the ssl volume. HTTPS works immediately, but the browser warns. Vesana ships no ACME client — a Let's Encrypt certificate comes via path 2 or path 3.

Path 2 — Your own certificate in the stack volume

The stack nginx reads exactly ONE pair from the ssl volume: /etc/nginx/ssl/fullchain.pem + privkey.pem. It applies to every hostname this nginx serves — with the split-container mode active that includes the admin name (the certificate must then cover both names).

cd /opt/vesana   # your Vesana directory

# Let's Encrypt with certbot on the host (free port 80 briefly):
docker compose -f docker-compose.prod.yml stop nginx
sudo certbot certonly --standalone -d vesana.example.com --agree-tos --non-interactive --email admin@example.com
docker compose -f docker-compose.prod.yml start nginx

# Copy the pair into the volume and reload nginx (same for a certificate from your own CA):
docker compose -f docker-compose.prod.yml cp /etc/letsencrypt/live/vesana.example.com/fullchain.pem nginx:/etc/nginx/ssl/fullchain.pem
docker compose -f docker-compose.prod.yml cp /etc/letsencrypt/live/vesana.example.com/privkey.pem  nginx:/etc/nginx/ssl/privkey.pem
docker compose -f docker-compose.prod.yml exec nginx nginx -s reload

Cert format: PEM (X.509 with intermediate chain). Key: unencrypted PEM. On renewal run the same two cp lines + reload — as a certbot deploy hook (/etc/letsencrypt/renewal-hooks/deploy/vesana.sh) this happens automatically.

Path 3 — Reverse proxy in front

When an nginx / Traefik / HAProxy / Caddy already fronts the server and TLS terminates there:

In .env:

TRUST_PROXY=true
HTTP_PORT=8080      # instead of 80
HTTPS_PORT=8443     # instead of 443 — we don't need TLS here, but the container wants the port

In the external reverse proxy:

server {
  listen 443 ssl http2;
  server_name vesana.example.com;

  ssl_certificate     /etc/letsencrypt/live/vesana.example.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/vesana.example.com/privkey.pem;

  location / {
    proxy_pass http://127.0.0.1:8080;
    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_set_header X-Forwarded-Host  $host;

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

    # Response headers of the login (Set-Cookie with the session token that
    # carries the role's permissions) exceed nginx's default buffer (4 KB).
    # Without these lines: "upstream sent too big header" → 502 on login for
    # regular users (super admins still fit).
    proxy_buffer_size       32k;
    proxy_buffers           8 32k;
    proxy_busy_buffers_size 64k;
  }

  # Receiver endpoint for agent / collector
  location /receiver {
    proxy_pass http://127.0.0.1:8080;
    proxy_set_header Host              $host;
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_request_buffering off;
    client_max_body_size 50m;       # for compressed log packets
  }
}

Important: set TRUST_PROXY=true, otherwise the backend sees the reverse proxy IP, not the real client IP — audit log and rate limit would be wrong.

Test self-host (example)

The test instance app.vesana.org runs an nginx in front terminating TLS and proxying onto the stack at ports 8180/8181:

server {
  listen 443 ssl;
  server_name app.vesana.org;
  ssl_certificate     /etc/letsencrypt/live/app.vesana.org/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/app.vesana.org/privkey.pem;

  location / {
    proxy_pass https://127.0.0.1:8181;
    proxy_ssl_verify off;     # internal stack has self-signed
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto https;
    proxy_buffer_size 32k;
    proxy_buffers 8 32k;
    proxy_busy_buffers_size 64k;
  }
}

proxy_ssl_verify off is acceptable internally — traffic on 127.0.0.1 isn't public.

CSP

The frontend sets a strict CSP. For your own iframes or embedded content, extend iframe_allowlist in system_settings.

Header hygiene

The built-in nginx sets:

  • Strict-Transport-Security: max-age=31536000; includeSubDomains
  • X-Content-Type-Options: nosniff
  • X-Frame-Options: SAMEORIGIN
  • Referrer-Policy: strict-origin-when-cross-origin
  • Permissions-Policy: geolocation=(), microphone=()

In your reverse proxy: set the same set.

TLS test

curl -I https://vesana.example.com
# Should return HSTS header and 200

External tools:

  • SSL Labs Test for cert chain and cipher suite rating
  • testssl.sh for local validation

Goal: A or A+ at SSL Labs.

Next