Networking
Reach Shotstash from outside your LAN with HTTPS through a reverse proxy, Tailscale or Cloudflare Tunnel.
Shotstash listens on plain HTTP on one port (SHOTSTASH_PORT, default 3005) and never terminates TLS itself. For anything beyond your LAN, put something in front of it that does: a reverse proxy, Tailscale or Cloudflare Tunnel. Two settings tell the app about it.
The two settings
| Variable | Set it to | Why |
|---|---|---|
APP_URL | the public address people open, for example https://media.example.com | emails and share links are built from it |
TRUST_PROXY | true only when every request reaches the app through a proxy that sets the forwarded headers itself | the app then reads the client IP and the scheme from those headers |
What the app trusts with TRUST_PROXY=true
- Client IP (rate limits, logs): the
CF-Connecting-IPheader when present, otherwise the first (leftmost) entry ofX-Forwarded-For. - Scheme (the
Securecookie flag,Strict-Transport-Security,schemeMismatch): the first entry ofX-Forwarded-Proto.
Both headers are believed as they arrive, so the proxy in front must replace them, never pass on what a client sent:
- remove any
CF-Connecting-IPthe client sent (unless the proxy is Cloudflare itself, which sets it); - set
X-Forwarded-Forto the client address only, not append to a client-supplied list (the app reads the leftmost entry, which a client controls in an appended list); - set
X-Forwarded-Prototo the scheme the client used.
Keep TRUST_PROXY=false unless every request comes through such a proxy. If the app port is also reachable directly (or through a second path that passes headers on), anyone can send those headers and pick their own IP and scheme. Bind the published port to the loopback address when the proxy runs on the same machine, so the app is not reachable around it. Put it in an override file of your own rather than editing docker-compose.yml (which git pull updates):
# docker-compose.override.yml
services:
app:
ports: !override
- "127.0.0.1:${SHOTSTASH_PORT:-3005}:3005"Then append the file to COMPOSE_FILE in .env. While COMPOSE_FILE is set (docker/init.sh sets it to the published images), Compose does not load docker-compose.override.yml by itself:
COMPOSE_FILE=docker-compose.yml:docker-compose.images.yml:docker-compose.override.ymlEntries are relative paths separated by :, with no Windows drive letters. !override needs Docker Compose 2.24.4 or newer.
With TRUST_PROXY=false the app uses the TCP peer as the client IP and the plain http scheme of its own port.
What the proxy must allow
- WebSockets on
/api/graphql(live job progress, discussion and notifications). - Large request bodies. Uploads are sent in parts of 16 MiB (larger parts only for files above about 160 GB), and a processing worker uploads its whole output in one request. Do not cap the body size below that, and do not buffer requests to disk.
- Long requests. Downloads and ZIP archives stream for as long as they take.
Caddy
Caddy gets and renews certificates by itself and forwards WebSockets.
media.example.com {
reverse_proxy 127.0.0.1:3005 {
header_up -Cf-Connecting-Ip
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
flush_interval -1
}
}Then in .env:
APP_URL=https://media.example.com
TRUST_PROXY=truenginx
# In the http block:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl;
http2 on;
server_name media.example.com;
ssl_certificate /etc/letsencrypt/live/media.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/media.example.com/privkey.pem;
# Upload parts and worker outputs: no size cap, no buffering.
client_max_body_size 0;
proxy_request_buffering off;
proxy_buffering off;
proxy_read_timeout 1h;
proxy_send_timeout 1h;
location / {
proxy_pass http://127.0.0.1:3005;
proxy_http_version 1.1;
proxy_set_header Host $host;
# Replace, never append: the app trusts the first entry.
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header CF-Connecting-IP "";
# WebSockets for /api/graphql
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}Use the same two .env lines as with Caddy. If nginx itself sits behind another proxy or a load balancer, configure set_real_ip_from for that hop so $remote_addr is the client, not the balancer.
Tailscale
Tailscale gives every device on your tailnet a private address, so you can reach Shotstash from anywhere without opening a port.
- Private, HTTP: install Tailscale on the server and open
http://<machine-name>:3005from your devices. SetAPP_URL=http://<machine-name>:3005(the MagicDNS name) and keepTRUST_PROXY=false: nothing sits in front of the app. - Private, HTTPS: put the Caddy example above in front, with the tailnet name (
<machine-name>.<tailnet>.ts.net) as the site address; Caddy gets the certificate from the local Tailscale daemon. SetAPP_URLto thathttps://address andTRUST_PROXY=trueonly when the app port is bound to127.0.0.1so Caddy is the only way in.
Do not set TRUST_PROXY=true behind tailscale serve or Tailscale Funnel alone: they pass client headers on rather than replacing them, so the rule above is not met. Share links only work for people on your tailnet; to hand links to clients outside it, use a reverse proxy or a tunnel.
Cloudflare Tunnel
A tunnel publishes the app on your domain without opening a port: cloudflared makes an outgoing connection and Cloudflare forwards requests through it. Cloudflare sets CF-Connecting-IP and X-Forwarded-Proto itself on every request, which meets the rule above.
cloudflared tunnel create shotstash
cloudflared tunnel route dns shotstash media.example.com~/.cloudflared/config.yml:
tunnel: shotstash
credentials-file: /home/you/.cloudflared/<tunnel-id>.json
ingress:
- hostname: media.example.com
service: http://127.0.0.1:3005
- service: http_status:404Then set APP_URL=https://media.example.com and TRUST_PROXY=true, and bind the app port to 127.0.0.1 so the tunnel is the only way in. If anything else can reach the port (the LAN, a second hostname without Cloudflare), keep TRUST_PROXY=false.
Limits to know:
- Cloudflare limits a request body by plan (100 MB on the free plan). Upload parts stay well below it, but a processing worker that sends its output through the tunnel can exceed it: run workers on the same network as the app (the bundled
workerservice talks tohttp://app:3005directly). - WebSockets work through tunnels without extra settings.
Checking the result: schemeMismatch
GET /api/health reports schemeMismatch: true when APP_URL says https but the request arrived as http (or the other way round). Check it through the public address, so the request takes the same path as everyone else's. The detailed body (with schemeMismatch) is shown to a super admin session:
TOKEN=$(curl -s -X POST https://media.example.com/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"owner@example.com","password":"..."}' | sed -n 's/.*"token":"\([^"]*\)".*/\1/p')
curl -s https://media.example.com/api/health -H "Authorization: Bearer $TOKEN"Expect "schemeMismatch":false. A mismatch behind a proxy usually means the proxy does not set X-Forwarded-Proto, or TRUST_PROXY is not true. Without a super admin account at hand, open a share link from another device: its media must load over https without browser warnings.
Checklist
APP_URLis the address people open, with the right scheme.- The proxy replaces
X-Forwarded-ForandX-Forwarded-Protoand drops a client-sentCF-Connecting-IP(Cloudflare sets its own). TRUST_PROXY=trueonly when that proxy is the only way in, and the app port is bound to127.0.0.1(or firewalled)./api/healththrough the public address showsschemeMismatch: false.- A share link opened on another device works, and live job progress updates without a reload (WebSockets pass).