Reverse proxy setup
You can run TravStats over plain HTTP on your LAN forever. But if you want HTTPS or remote access, you’ll need a reverse proxy in front. This page has working configs for the five most common choices.
Why TravStats cares which proxy
Section titled “Why TravStats cares which proxy”Two things have to land at the backend:
Hostheader — what hostname the user typed. Used in emails (the “click here” links).X-Forwarded-Proto—httporhttps. TravStats reads this to decide whether to set theSecureflag on the JWT cookie. If the cookie is setSecureand the browser arrives over HTTP, the cookie is dropped → instant logout.
All five proxies below forward both correctly with the configs shown. If you write your own from scratch, those two headers are the things to verify.
Cloudflare Tunnel (recommended for public domains)
Section titled “Cloudflare Tunnel (recommended for public domains)”The smoothest option for “I have a domain, I want HTTPS, and I don’t want any ports exposed”.
How it works
Section titled “How it works”You install cloudflared somewhere on your network. It opens an
outbound connection to Cloudflare and tunnels traffic back to
your TravStats container. Cloudflare handles TLS termination, DDoS
protection, and a free public hostname.
No ports forwarded. No router config. Works from anywhere on the internet — without exposing your home IP.
-
Sign in at dash.cloudflare.com, add a domain you control, set up the DNS.
-
Zero Trust → Networks → Tunnels → Create a tunnel. Name it
travstats. Cloudflare gives you acloudflaredinstall command and a token. -
Run
cloudflaredsomewhere it can reach the TravStats container. Easiest: as a sidecar service in your docker-compose:# docker-compose.prod.yml — add to existing services:cloudflared:image: cloudflare/cloudflared:latestcontainer_name: travstats-cloudflaredrestart: unless-stoppedcommand: tunnel --no-autoupdate runenvironment:TUNNEL_TOKEN: ${CLOUDFLARED_TUNNEL_TOKEN}networks:- travstats-networkdepends_on:- appAnd
CLOUDFLARED_TUNNEL_TOKEN=…in.envnext toDB_PASSWORD. -
Back in the Cloudflare dashboard, set up a Public Hostname:
- Subdomain:
travstats - Domain: your domain
- Service:
http://travstats-app:80(the container hostname inside the docker network)
- Subdomain:
-
Done.
https://travstats.example.comworks from anywhere, with valid TLS.
Cookie behavior
Section titled “Cookie behavior”Cloudflare Tunnel forwards X-Forwarded-Proto: https automatically.
TravStats sets Secure cookies; nothing extra to configure.
The simplest non-cloud option for an open homelab.
Install
Section titled “Install”If you don’t already run Caddy, the caddyserver/caddy image works
as a sidecar:
caddy: image: caddy:latest container_name: travstats-caddy restart: unless-stopped ports: - "80:80" - "443:443" volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - travstats-caddy-data:/data - travstats-caddy-config:/config networks: - travstats-network
volumes: travstats-caddy-data: travstats-caddy-config:Caddyfile
Section titled “Caddyfile”A two-line site block does all the work:
travstats.example.com { reverse_proxy travstats-app:80}Caddy fetches a Let’s Encrypt cert automatically, sets up HTTPS,
forwards everything correctly. X-Forwarded-Proto is set per
default — TravStats picks up the Secure cookie behaviour.
For internal-only use without a public domain, use a *.local
hostname and Caddy’s tls internal directive:
travstats.local { tls internal reverse_proxy travstats-app:80}The browser will warn about the self-signed cert; accept once and move on.
The classic.
Snippet for an existing nginx install
Section titled “Snippet for an existing nginx install”In your sites-enabled (or the equivalent for your distro):
server { listen 443 ssl http2; server_name travstats.example.com;
ssl_certificate /etc/letsencrypt/live/travstats.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/travstats.example.com/privkey.pem;
# Pass headers TravStats relies 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;
location / { proxy_pass http://127.0.0.1:3000; # APP_PORT from your .env proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 60s; }}
server { listen 80; server_name travstats.example.com; return 301 https://$host$request_uri;}The four proxy_set_header lines are the important bit.
Especially X-Forwarded-Proto — without it the cookie is set
without Secure and the browser drops it on HTTPS, causing a login
loop.
certbot for Let’s Encrypt
Section titled “certbot for Let’s Encrypt”sudo certbot --nginx -d travstats.example.comAuto-renewal lands in the system cron. Done.
Traefik
Section titled “Traefik”Common in container-heavy homelabs because it auto-discovers services from labels.
docker-compose labels
Section titled “docker-compose labels”Add to the existing app service:
services: app: image: ghcr.io/abrechen2/travstats:${VERSION:-latest} # ... existing config labels: - "traefik.enable=true" - "traefik.http.routers.travstats.rule=Host(`travstats.example.com`)" - "traefik.http.routers.travstats.entrypoints=websecure" - "traefik.http.routers.travstats.tls.certresolver=letsencrypt" - "traefik.http.services.travstats.loadbalancer.server.port=80" networks: - traefik-public # Traefik's own network - travstats-networkAssumes Traefik is already up with an letsencrypt certresolver
configured. The passHostHeader=true and X-Forwarded-Proto are
set by default — TravStats works without further customisation.
Tailscale (Funnel or Serve)
Section titled “Tailscale (Funnel or Serve)”For “I want it on my private network without exposing anything to the public internet”.
Tailscale Serve (LAN only)
Section titled “Tailscale Serve (LAN only)”On the host running TravStats:
sudo tailscale serve --bg --https=443 http://localhost:3000Now https://travstats.<your-tailnet>.ts.net works from any device
on your tailnet, with a valid Let’s Encrypt cert provisioned by
Tailscale. No port-forwards, no public DNS records.
Tailscale Funnel (public)
Section titled “Tailscale Funnel (public)”If you do want public access:
sudo tailscale funnel --bg --https=443 http://localhost:3000Same as Serve but the URL is reachable from the public internet. Free tier supports it for a single domain.
Cookie behavior
Section titled “Cookie behavior”Tailscale’s reverse proxy forwards X-Forwarded-Proto: https —
TravStats sets Secure cookies, browsers honour them.
Mixing: Cloudflare Tunnel + Tailscale on different paths
Section titled “Mixing: Cloudflare Tunnel + Tailscale on different paths”Some homelabs run both — public domain via Cloudflare Tunnel for
“share with friends” + Tailscale Serve for “fast LAN access without
the Cloudflare hop”. TravStats handles both fine; just make sure
each proxy forwards the right Host and X-Forwarded-Proto and
both tunnels point at travstats-app:80 (or localhost:3000 from
the host).
What can go wrong
Section titled “What can go wrong”| Symptom | Cause | Fix |
|---|---|---|
| Login loop, immediately back to login screen | X-Forwarded-Proto not forwarded | Add proxy_set_header X-Forwarded-Proto $scheme; (nginx) or equivalent |
Login works on localhost:3000 but not on the public domain | Same as above | |
| ”Site can’t be reached” with cert warning | Cert not provisioned (Let’s Encrypt rate-limit, DNS not propagated) | Wait, retry, check caddy --version / certbot certificates |
Site loads but invitation emails point at localhost:3000 | Admin → Settings → Public URL still on the old value | Update it to your real URL |
| Slow first load, then fast | Cold-start of the bundled Ollama container | Normal, Ollama warms up after first request |
The Troubleshooting page has the full proxy-related symptom catalog.
Don’t expose the database directly
Section titled “Don’t expose the database directly”The bundled db and ollama services are on the internal
travstats-network for a reason. Do not add port mappings to
expose them to the host or the public internet — there’s no
reason to, and Postgres exposed on the public internet is a
recipe for problems.
The only service that needs an exposed port is app:80, and even
that is usually fronted by a reverse proxy rather than directly
exposed.