Reverse proxy setup for the FUTO Notes server (nginx, Caddy, Traefik)
The FUTO Notes server speaks plain HTTP on one port, so to reach it from outside your network you put a TLS reverse proxy in front of it. The proxy has three jobs: pass the live sync event stream through without buffering it, accept request bodies up to 100 MiB, and not cut off slow uploads. Caddy does all three with no extra settings; nginx and Traefik each need a line or two.
Serve the server at the root of its own hostname, such as notes.example.com, then enter the HTTPS URL as the Server URL in the app’s sync settings on every device.
What the proxy has to handle
The proxy has to stream live sync events without buffering them, accept request bodies up to 100 MiB, and let slow uploads finish. It needs no special headers.
Live sync events. Each connected device holds open GET /api/sync/events, a Server-Sent Events stream (Content-Type: text/event-stream). The server sends a ready event on connect, a change event whenever another device writes, and a ping every 25 seconds. It also sends X-Accel-Buffering: no. A proxy that buffers this response holds the events back, and changes stop arriving live.
Request size. A single encrypted blob can be up to 100 MiB (104,857,600 bytes), and a batch upload up to 32 MiB. The proxy’s body limit must be at least 100 MiB, or large notes and attachments fail with a 413 from the proxy.
Timeouts. The 25-second ping keeps the event stream from looking idle, so any proxy idle or read timeout above about 30 seconds is enough for it. Uploads are different: a 100 MiB blob over a slow uplink can take minutes, so the proxy must not cap the total time it spends reading a request.
Headers. None are required. The server ignores X-Forwarded-For, X-Forwarded-Proto and Host. The session cookie is marked Secure by default, which is correct behind HTTPS, and the apps use a bearer token anyway.
nginx
server { listen 443 ssl; server_name notes.example.com;
ssl_certificate /etc/letsencrypt/live/notes.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/notes.example.com/privkey.pem;
# A single blob can be 100 MiB. nginx's default is 1m. client_max_body_size 100m;
location / { proxy_pass http://127.0.0.1:3005; proxy_http_version 1.1; proxy_set_header Connection ""; proxy_set_header Host $host; }
location /api/sync/events { proxy_pass http://127.0.0.1:3005; proxy_http_version 1.1; proxy_set_header Connection ""; proxy_set_header Host $host; proxy_buffering off; proxy_cache off; proxy_read_timeout 1h; }}client_max_body_size is the setting people miss. nginx’s default of 1 MB rejects any note or attachment bigger than that. Setting it to exactly 100m matches the server’s own limit.
nginx already honors the server’s X-Accel-Buffering: no header. proxy_buffering off on the events location makes that independent of the header, and proxy_read_timeout 1h gives margin beyond the 25-second ping; nginx’s 60-second default also works. Get the certificate with certbot or whatever you already use.
Caddy
notes.example.com { reverse_proxy localhost:3005}That is the whole config. Caddy gets the certificate itself, flushes text/event-stream responses immediately, and has no default request body limit.
If Caddy runs in its own container, localhost means Caddy’s container, not the host. Put both containers on a shared Docker network and use reverse_proxy server:3000, or point it at the host’s address.
Traefik
With Traefik’s Docker provider, add labels to the server with a docker-compose.override.yml beside docker-compose.yml. Compose merges it on every docker compose up -d, and your changes survive when you re-download the compose file.
services: server: labels: - traefik.enable=true - traefik.http.routers.futo-notes.rule=Host(`notes.example.com`) - traefik.http.routers.futo-notes.entrypoints=websecure - traefik.http.routers.futo-notes.tls.certresolver=letsencrypt - traefik.http.services.futo-notes.loadbalancer.server.port=3000 networks: - proxy
networks: proxy: external: trueReplace websecure, letsencrypt and proxy with the entrypoint, certificate resolver and Docker network names from your own Traefik setup. The port is 3000 because Traefik talks to the container directly, not to the published host port.
Traefik v3 limits the time it spends reading a whole request, body included, to 60 seconds by default. An upload that takes longer fails. Raise it on the entrypoint in Traefik’s static configuration:
entryPoints: websecure: address: ":443" transport: respondingTimeouts: readTimeout: 10mor on the command line, --entrypoints.websecure.transport.respondingTimeouts.readTimeout=10m. Traefik streams the event stream and has no default body limit. If you add the Buffering middleware, set maxRequestBodyBytes to at least 104857600.
Tailscale
Tailscale gives the machine an HTTPS name under ts.net with no port opened on your router and no certificate to manage. Your tailnet needs MagicDNS and HTTPS certificates turned on.
To reach the server only from your own devices on the tailnet:
tailscale serve --bg 3005To reach it from anywhere on the internet, with Tailscale Funnel:
tailscale funnel --bg 3005Either command prints the URL, of the form https://<machine>.<tailnet>.ts.net. Use that as the Server URL. With serve, every device that syncs needs Tailscale running. Turn it off again with tailscale serve --https=443 off or tailscale funnel --https=443 off.
Keep the plain HTTP port private
If the proxy runs on the same machine, publish the FUTO Notes server on loopback only by setting this in .env:
FUTO_NOTES_PORT=127.0.0.1:3005Then run docker compose up -d. Otherwise the unencrypted port stays reachable alongside the proxy, because the compose file publishes port 3005 on every interface. Don’t count on a host firewall such as ufw to cover it: Docker’s published ports bypass those rules.
The login rate limit behind a proxy
Behind any proxy, including Tailscale serve and funnel, every request comes from the proxy’s address, so all your devices and everyone on the internet share one bucket of 10 sign-in attempts a minute.
The sign-in endpoint allows 10 attempts per 60 seconds per client address, and after that it answers 429 Too Many Requests with a Retry-After header. The server uses the address of the TCP connection it receives. It never trusts X-Forwarded-For, and there is no setting to make it (TRUST_PROXY is ignored).
The limit still slows down password guessing. The cost is that a burst of wrong passwords, from a misconfigured device or from someone probing a public server, can block sign-in for everyone for up to a minute. Devices that are already signed in keep syncing, because only sign-in is limited, but sessions last 7 days, so each device signs in again at least weekly.
Check that it works
The server’s health check should answer through the proxy:
curl https://notes.example.com/health{"status":"ok","db":"connected"}To check the event stream isn’t buffered, sign in, then open the stream. This uses one login attempt and creates a session, so the last line signs out again. read -rs keeps the password out of your shell history; a password containing " or \ needs escaping in the JSON.
read -rs PWTOKEN=$(curl -s https://notes.example.com/api/auth/password/login \ -H 'Content-Type: application/json' -d "{\"password\":\"$PW\"}" | jq -r .token)curl -N -H "Authorization: Bearer $TOKEN" https://notes.example.com/api/sync/eventscurl -X POST -H "Authorization: Bearer $TOKEN" https://notes.example.com/api/auth/logoutevent: ready should print at once, then event: ping every 25 seconds. If nothing prints until you press Ctrl+C, the proxy is buffering. For what other status codes mean, see server errors and logs.
Next steps
- Connect the app: enter the HTTPS URL on each device.
- Errors, health, and logs: what a status code or log line means.
- Sync troubleshooting: when a device still does not sync.