Tailscale-only sss3 hosting¶
Use this route when sss3 runs on a home machine behind CGNAT or restrictive NAT, and Cloudflare's ~100 MB request-body cap is not acceptable. File bytes never touch Cloudflare:
browser
-> https://sss3.example.com
VPS: Caddy, public TLS
-> 100.x.y.z:5554
Tailscale Serve (tailnet only)
-> 127.0.0.1:5553
home: Caddy, CORS, Host preservation
-> sss3
interim server-side S3 calls
-> 100.x.y.z:5555
Tailscale Serve (tailnet only, no header stripping)
-> 127.0.0.1:5556
sss3 directly
The public entry point is a small VPS. The home machine makes only an outbound
Tailscale connection, so no port forwarding, public IPv4, or stable home IP is
needed. tailscale serve is not Funnel; it is reachable only inside the
tailnet and does not have Funnel's public bandwidth limits. The interim UI can
still be served through Cloudflare Tunnel; only the S3 hostname bypasses
Cloudflare.
interim signs the browser's PUT/GET for sss3.example.com; the browser sends
the bytes to the VPS, and the VPS forwards them to the home machine. interim
never proxies the file bytes, and Cloudflare never sees the upload.
1. Home machine: sss3 and Caddy¶
Start the existing stack from
compose.sss3.yaml
and
.env.sss3.example:
cp .env.sss3.example .env.sss3
# edit INTERIM_ORIGIN, S3_ACCESS_KEY, S3_SECRET_KEY, S3_BUCKET
docker compose -f compose.sss3.yaml --env-file .env.sss3 up -d
compose.sss3.yaml publishes Caddy on 127.0.0.1:5553. In
Caddyfile.sss3,
the reverse proxy must preserve the host and port:
Do not use {http.request.host} here. That placeholder strips the port. A
request to http://100.x.y.z:5554 is signed for Host: 100.x.y.z:5554;
stripping the port to 100.x.y.z produces SignatureDoesNotMatch from sss3.
The header_up -X-Amz-* line is the allowlist that prevents copy/ACL/tagging
operations; keep it on every public sss3 proxy.
Reload Caddy after the change:
or restart the container:
Expose the loopback Caddy listener to the tailnet:
5553 is the home Caddy listener. The compose file also publishes sss3
directly on 127.0.0.1:5556; expose that on a separate tailnet port so
server-side SigV4 requests never pass through Caddy:
5555 is the tailnet-facing direct sss3 port. Keep 127.0.0.1:5556 loopback
only; do not publish it publicly. The direct route is required because Caddy
strips every X-Amz-* request header, including X-Amz-Date, which would break
HeadObject, CopyObject, ListParts, and deletes. Never remove
header_up -X-Amz-* from the browser-facing listener to make a route work.
The Serve mapping is persistent across reboots because of --bg. To remove it:
INTERIM_ORIGIN in .env.sss3 must exactly match the origin serving the
interim UI, for example:
That origin is the only one Caddy allows in its CORS response.
2. VPS: DNS, firewall, and TLS edge¶
On the VPS:
- Point
sss3.example.comat the VPS with anA(orAAAA) record. - Set the record to DNS only in Cloudflare if Cloudflare manages the zone. A proxied record puts the 100 MB limit back in the path and hides the real certificate.
- Remove
sss3.example.comfrom any Cloudflare Tunnel ingress. A tunnel hostname is proxied by definition and would override the VPS record. - Allow inbound TCP
80and443. - Make sure Tailscale is connected to the same tailnet as the home machine.
Verify tailnet reachability from the VPS:
Add a site block to the VPS Caddyfile. If Caddy already serves other sites, append this block; do not replace the existing configuration:
sss3.example.com {
reverse_proxy 100.x.y.z:5554 {
header_up Host {http.request.hostport}
# Allowlist: strip every X-Amz-* request header so a presigned PUT can
# never become CopyObject/UploadPartCopy or an ACL/tagging/SSE operation.
header_up -X-Amz-*
}
}
The VPS Caddy only terminates public TLS and proxies. The home Caddy still
handles CORS, so do not add a second Access-Control-Allow-Origin here; two
values make browsers reject the response. The checked-in
Caddyfile.sss3
strips the same headers. Caddy obtains the public certificate automatically once
the DNS record and ports are correct.
Reload and test:
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
curl -vkI https://sss3.example.com/
A 403 or 404 from sss3 is expected for an unsigned request. What matters is
a valid Let's Encrypt certificate and an HTTP response from sss3, not a
Cloudflare error or a 502. A Via: 1.1 Caddy header is normal; both Caddys
may add one.
3. interim configuration¶
Set the public S3 hostname for browser presigned URLs, and the tailnet address for server-side calls:
PUBLIC_BASE_URL=https://interim.example.com
S3_PUBLIC_ENDPOINT=https://sss3.example.com
S3_ENDPOINT=http://100.x.y.z:5555
S3_REGION=us-east-1
S3_BUCKET=interim
S3_FORCE_PATH_STYLE=true
S3_AUTO_CREATE_BUCKET=false
S3_PUBLIC_ENDPOINT_BLOCKS_COPY=true
MAX_FILE_SIZE=5GB
UPLOAD_URL_TTL=2h
UPLOAD_INTENT_TTL=3h
Important details:
S3_PUBLIC_ENDPOINTis embedded in browser presigned PUT/GET URLs. It must be the public VPS hostname.S3_ENDPOINTis used by interim forHeadObject,CopyObject,ListParts,ListObjectsV2, and deletes. It must use the direct sss3 tailnet port (5555in this guide), never the public VPS Caddy and never the home Caddy on5554. Caddy strips everyX-Amz-*request header, includingX-Amz-Date, so server-side signing fails through it. If interim cannot reach the tailnet, place it on the tailnet or provide a private route.- If interim shares a Docker network with sss3,
S3_ENDPOINTcan instead behttp://stupid-simple-s3:5553. Do not pointS3_ENDPOINTat the Caddy container; that path is only for browsers and always stripsX-Amz-*. - The app's CSP already includes
S3_PUBLIC_ENDPOINT, so no code change is needed for the browser to reach the new public S3 hostname. S3_BUCKETmust exactly match sss3'sSTUPID_BUCKET_NAMEin.env.sss3.S3_ACCESS_KEYandS3_SECRET_KEYmust exactly matchSTUPID_RW_ACCESS_KEYandSTUPID_RW_SECRET_KEY.S3_AUTO_CREATE_BUCKET=falseis preferred once sss3's configured bucket exists. sss3 createsSTUPID_BUCKET_NAMEat startup.MAX_FILE_SIZEmay exceed 5 GB only with multipart enabled. Multipart parts are separate requests and are not constrained by Cloudflare's 100 MB body cap here because the browser talks to the VPS directly.- Increase
UPLOAD_URL_TTLandUPLOAD_INTENT_TTLfor slow home uplinks.UPLOAD_INTENT_TTLmust be at leastUPLOAD_URL_TTL.
Restart interim after changing .env:
If the interim UI is served through Cloudflare Tunnel, PUBLIC_BASE_URL and
INTERIM_ORIGIN stay on the Cloudflare hostname. The browser still uploads
directly to sss3.example.com, which resolves to the VPS.
4. Verify the route¶
From the VPS:
The address should be the VPS public IP, and the response should come from sss3/Caddy, not Cloudflare. Then upload a file larger than 100 MB through the interim UI.
Useful checks:
tailscale serve statuson the home machine should show bothtcp://100.x.y.z:5554 -> tcp://127.0.0.1:5553andtcp://100.x.y.z:5555 -> tcp://127.0.0.1:5556.tailscale ping 100.x.y.zshows whether traffic is direct or relayed. DERP relay works, but direct is faster for large files.- The object should appear under the sss3 data directory on the home machine.
curl -Ishould not showserver: cloudflarefor the S3 hostname.- From the VPS,
nc -vz 100.x.y.z 5555must succeed. If it does not, server-side S3 operations fail even though browser uploads still work through5554.
Troubleshooting this route¶
SignatureDoesNotMatch on startup or signed requests
A proxy stripped the port. Use header_up Host {http.request.hostport} in
both Caddys. Also verify S3_REGION=us-east-1, the bucket name, and the
credentials.
CreateBucket fails
The bucket name differs from sss3's configured bucket, or the credentials do
not match. Set S3_BUCKET equal to STUPID_BUCKET_NAME and restart sss3 so
it creates the bucket, then set S3_AUTO_CREATE_BUCKET=false.
502 from the VPS
tailscale serve is not running, the tailnet ACL blocks the VPS, or the home
Caddy is down. Run nc -vz 100.x.y.z 5554 from the VPS and
tailscale serve status at home.
Browser CORS error
INTERIM_ORIGIN must be the exact scheme and host of the page making the
request. Do not add CORS headers on both Caddys.
Certificate error
The DNS record is proxied, port 80/443 is blocked, or the record still points at Cloudflare Tunnel.
Upload still fails at 100 MB
The S3 hostname is still resolving to Cloudflare. Check the DNS record and remove the tunnel ingress.
Upload URL expires
Raise UPLOAD_URL_TTL and UPLOAD_INTENT_TTL.
Operational notes¶
- Tailscale ACLs should allow the VPS to reach the home machine on TCP
5554(browser path) and TCP5555(interim server-side S3 path). The public internet reaches only the VPS Caddy; sss3 remains tailnet-only. - The VPS relays every upload and download, so its monthly traffic allowance and bandwidth matter. The home disk still stores the bytes.
- Tailscale Serve is tailnet-only. Do not use Tailscale Funnel for this route; Funnel is public and has its own bandwidth limits.
sss3is still HTTP-only and path-style. KeepS3_FORCE_PATH_STYLE=trueand regionus-east-1.- sss3's default
STUPID_MAX_OBJECT_SIZEis5GB. Keep interim'sMAX_FILE_SIZEat or below that, or raise it andSTUPID_MAX_OBJECT_SIZE. RaiseSTUPID_MAX_PART_SIZEtogether withMULTIPART_PART_SIZEif the effective part size grows for very large files. If sss3 rejects normal part PUTs withSignatureDoesNotMatch, setMULTIPART_SIGN_PART_LENGTH=falseonly as a last resort and keep the provider-side part-size cap exactly aligned with interim'sMULTIPART_PART_SIZE.