Http/2 on Nextcloud instance

1. The problem I’m having:

Hello! I’ve been working on a highly custom Nextcloud stack for some time now, and at this point I have most things working except for http/2. I’ve tried a few different ways of accomplishing this, from hosting the web server with TLS directly to changing configurations around to attempt to force h2c.

I’m running the php-fpm version of Nextcloud with Caddy running the webserver through a php socket. I’ve managed to get internally managed TLS to work fine locally a couple times, but other than that I can’t get it to work through my reverse proxy, which is tsdproxy. So far this is the one problem that’s left me truly puzzled.

2. Error messages and/or full log output:

{"level":"info","ts":1743571160.791983,"logger":"http.log","msg":"server running","name":"srv0","protocols":["h1","h2c"]}
{"level":"info","ts":1743571160.7944508,"logger":"tls","msg":"storage cleaning happened too recently; skipping for now","storage":"FileStorage:/data/caddy","instance":"f781f668-54e7-4c38-b969-f025dcc1cbe9","try_again":1743657560.7944489,"try_again_in":86399.999999655}
{"level":"info","ts":1743571160.7945523,"logger":"tls","msg":"finished cleaning storage units"}
{"level":"info","ts":1743571160.8629727,"msg":"autosaved config (load with --resume flag)","file":"/config/caddy/autosave.json"}
{"level":"info","ts":1743571160.8630567,"msg":"serving initial configuration"}
{"level":"debug","ts":1743571168.10378,"logger":"http.handlers.rewrite","msg":"rewrote request","request":{"remote_ip":"174.39.0.10","remote_port":"52682","client_ip":"100.115.173.26","proto":"HTTP/1.1","method":"GET","host":"cloud.possum-godzilla.ts.net","uri":"/index.php","headers":{"User-Agent":["Mozilla/5.0 (X11; Linux x86_64; rv:135.0) Gecko/20100101 Firefox/135.0"],"Accept":["text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Proto":["https"],"Cookie":["REDACTED"],"Sec-Fetch-Dest":["document"],"Sec-Fetch-Site":["none"],"Sec-Gpc":["1"],"Accept-Language":["en-US,en;q=0.5"],"Priority":["u=0, i"],"Sec-Fetch-Mode":["navigate"],"X-Forwarded-For":["100.115.173.26"],"Sec-Fetch-User":["?1"],"Upgrade-Insecure-Requests":["1"],"X-Forwarded-Host":["cloud.possum-godzilla.ts.net"]}},"method":"GET","uri":"/index.php"}
{"level":"debug","ts":1743571168.1039574,"logger":"http.handlers.reverse_proxy","msg":"selected upstream","dial":"/var/run/nextcloud/php-fpm.sock","total_upstreams":1}
{"level":"debug","ts":1743571168.1046672,"logger":"http.handlers.reverse_proxy","msg":"upstream roundtrip","upstream":"unix//var/run/nextcloud/php-fpm.sock","duration":0.000537566,"request":{"remote_ip":"174.39.0.10","remote_port":"52682","client_ip":"100.115.173.26","proto":"HTTP/1.1","method":"GET","host":"cloud.possum-godzilla.ts.net","uri":"/index.php","headers":{"X-Forwarded-Proto":["https"],"Sec-Fetch-Site":["none"],"Sec-Fetch-Dest":["document"],"Accept-Language":["en-US,en;q=0.5"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Priority":["u=0, i"],"X-Forwarded-For":["100.115.173.26, 174.39.0.10"],"X-Forwarded-Host":["cloud.possum-godzilla.ts.net"],"Sec-Fetch-User":["?1"],"Accept":["text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"],"Sec-Gpc":["1"],"Cookie":["REDACTED"],"Upgrade-Insecure-Requests":["1"],"User-Agent":["Mozilla/5.0 (X11; Linux x86_64; rv:135.0) Gecko/20100101 Firefox/135.0"],"Sec-Fetch-Mode":["navigate"]}},"error":"read unix @->/var/run/nextcloud/php-fpm.sock: read: connection reset by peer"}
{"level":"error","ts":1743571168.1048815,"logger":"http.log.error","msg":"read unix @->/var/run/nextcloud/php-fpm.sock: read: connection reset by peer","request":{"remote_ip":"174.39.0.10","remote_port":"52682","client_ip":"100.115.173.26","proto":"HTTP/1.1","method":"GET","host":"cloud.possum-godzilla.ts.net","uri":"/","headers":{"Sec-Gpc":["1"],"X-Forwarded-For":["100.115.173.26"],"Sec-Fetch-Mode":["navigate"],"User-Agent":["Mozilla/5.0 (X11; Linux x86_64; rv:135.0) Gecko/20100101 Firefox/135.0"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cookie":["REDACTED"],"Sec-Fetch-Site":["none"],"Upgrade-Insecure-Requests":["1"],"Accept":["text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"],"X-Forwarded-Proto":["https"],"Accept-Language":["en-US,en;q=0.5"],"Priority":["u=0, i"],"Sec-Fetch-User":["?1"],"X-Forwarded-Host":["cloud.possum-godzilla.ts.net"],"Sec-Fetch-Dest":["document"]}},"duration":0.001605305,"status":502,"err_id":"p8bdzn5fi","err_trace":"reverseproxy.statusError (reverseproxy.go:1373)"}
{"level":"debug","ts":1743571169.126225,"logger":"http.handlers.file_server","msg":"sanitized path join","site_root":"/var/www/html","fs":"","request_path":"/index.php/apps/files/preview-service-worker.js","result":"/var/www/html/index.php/apps/files/preview-service-worker.js"}
{"level":"debug","ts":1743571169.1267302,"logger":"http.handlers.rewrite","msg":"rewrote request","request":{"remote_ip":"174.39.0.10","remote_port":"52682","client_ip":"100.115.173.26","proto":"HTTP/1.1","method":"GET","host":"cloud.possum-godzilla.ts.net","uri":"/index.php","headers":{"Cookie":["REDACTED"],"X-Forwarded-Host":["cloud.possum-godzilla.ts.net"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-US,en;q=0.5"],"Sec-Fetch-Dest":["serviceworker"],"X-Forwarded-For":["100.115.173.26"],"Accept":["*/*"],"Priority":["u=4"],"Sec-Fetch-Site":["same-origin"],"Sec-Gpc":["1"],"Service-Worker":["script"],"User-Agent":["Mozilla/5.0 (X11; Linux x86_64; rv:135.0) Gecko/20100101 Firefox/135.0"],"Cache-Control":["max-age=0"],"Sec-Fetch-Mode":["same-origin"],"X-Forwarded-Proto":["https"]}},"method":"GET","uri":"/index.php"}
{"level":"debug","ts":1743571169.1268878,"logger":"http.handlers.reverse_proxy","msg":"selected upstream","dial":"/var/run/nextcloud/php-fpm.sock","total_upstreams":1}
{"level":"debug","ts":1743571169.1275764,"logger":"http.handlers.reverse_proxy","msg":"upstream roundtrip","upstream":"unix//var/run/nextcloud/php-fpm.sock","duration":0.000600114,"request":{"remote_ip":"174.39.0.10","remote_port":"52682","client_ip":"100.115.173.26","proto":"HTTP/1.1","method":"GET","host":"cloud.possum-godzilla.ts.net","uri":"/index.php","headers":{"Sec-Fetch-Site":["same-origin"],"Sec-Fetch-Mode":["same-origin"],"X-Forwarded-For":["100.115.173.26, 174.39.0.10"],"Accept-Language":["en-US,en;q=0.5"],"Accept":["*/*"],"Cache-Control":["max-age=0"],"X-Forwarded-Proto":["https"],"X-Forwarded-Host":["cloud.possum-godzilla.ts.net"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (X11; Linux x86_64; rv:135.0) Gecko/20100101 Firefox/135.0"],"Priority":["u=4"],"Cookie":["REDACTED"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["serviceworker"],"Service-Worker":["script"]}},"error":"read unix @->/var/run/nextcloud/php-fpm.sock: read: connection reset by peer"}
{"level":"error","ts":1743571169.1277883,"logger":"http.log.error","msg":"read unix @->/var/run/nextcloud/php-fpm.sock: read: connection reset by peer","request":{"remote_ip":"174.39.0.10","remote_port":"52682","client_ip":"100.115.173.26","proto":"HTTP/1.1","method":"GET","host":"cloud.possum-godzilla.ts.net","uri":"/index.php/apps/files/preview-service-worker.js","headers":{"Service-Worker":["script"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept":["*/*"],"Sec-Gpc":["1"],"Priority":["u=4"],"Cache-Control":["max-age=0"],"X-Forwarded-Proto":["https"],"X-Forwarded-Host":["cloud.possum-godzilla.ts.net"],"Sec-Fetch-Site":["same-origin"],"User-Agent":["Mozilla/5.0 (X11; Linux x86_64; rv:135.0) Gecko/20100101 Firefox/135.0"],"Sec-Fetch-Mode":["same-origin"],"Cookie":["REDACTED"],"Accept-Language":["en-US,en;q=0.5"],"Sec-Fetch-Dest":["serviceworker"],"X-Forwarded-For":["100.115.173.26"]}},"duration":0.001771376,"status":502,"err_id":"3p8kiehmj","err_trace":"reverseproxy.statusError (reverseproxy.go:1373)"}

3. Caddy version:

v2.9.1 h1:OEYiZ7DbCzAWVb6TNEkjRcSCRGHVoZsJinoDR/n9oaY=

4. How I installed and ran Caddy:

As a docker compose stack.

a. System environment:

Host OS is Ubuntu 24.04.2 LTS

b. Command:


c. Service/unit/compose file:

networks:
  nc-internal:
    name: nc-internal
    ipam:
      driver: default
      config:
        - subnet: 174.53.0.0/24
  tsdproxy-net:
    external: true
services:
  nc-web:
    image: caddy:${CADDY_VERSION_TAG}
    container_name: nc-web
    ports:
      - ${NEXTCLOUD_PORT}:80
    networks:
      nc-internal:
        ipv4_address: 174.53.0.10
      tsdproxy-net:
        ipv4_address: 174.39.0.53
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - ./caddy:/data
      - ./config:/var/www/html
      - ./sockets/nextcloud:/var/run/nextcloud
      - ./sockets/notify-push:/var/run/notify-push
    restart: always
    #depends_on:
    #nc-app:
    #condition: service_healthy
    environment:
      - HOST_HOSTNAME=${HOST_HOSTNAME}
      - TRUSTED_PROXIES=${TRUSTED_PROXIES}
      - PHP_UPLOAD_LIMIT=${PHP_UPLOAD_LIMIT}
    labels:
      - tsdproxy.enable=true
      - tsdproxy.name=cloud
      - tsdproxy.dash.icon=si/nextcloud
      - tsdproxy.container_port=80
      - tsdproxy.scheme=http
      - tsdproxy.funnel=true
. . .

d. My complete Caddy config:

The key line here is php_fastcgi unix+h2c//var/run/nextcloud/php-fpm.sock {
This seems like the correct way to do it from what I can tell, however this just results in the connection reset by peer error. It works fine if the +h2c part it omitted.

{
	log {
    output file /data/access.log
    level DEBUG
	}
	servers {
		trusted_proxies static {$TRUSTED_PROXIES}
		trusted_proxies_strict
		client_ip_headers X-Forwarded-For X-Real-IP
		protocols h1 h2c
	}
}


:80 {
	request_body {
		max_size {$PHP_UPLOAD_LIMIT}
	}

	# Enable gzip but do not remove ETag headers
	encode {
		zstd
		gzip 4

		minimum_length 256

		match {
			header Content-Type application/atom+xml
			header Content-Type application/javascript
			header Content-Type application/json
			header Content-Type application/ld+json
			header Content-Type application/manifest+json
			header Content-Type application/rss+xml
			header Content-Type application/vnd.geo+json
			header Content-Type application/vnd.ms-fontobject
			header Content-Type application/wasm
			header Content-Type application/x-font-ttf
			header Content-Type application/x-web-app-manifest+json
			header Content-Type application/xhtml+xml
			header Content-Type application/xml
			header Content-Type font/opentype
			header Content-Type image/bmp
			header Content-Type image/svg+xml
			header Content-Type image/x-icon
			header Content-Type text/cache-manifest
			header Content-Type text/css
			header Content-Type text/plain
			header Content-Type text/vcard
			header Content-Type text/vnd.rim.location.xloc
			header Content-Type text/vtt
			header Content-Type text/x-component
			header Content-Type text/x-cross-domain-policy
		}
	}

	header {
		# Based on following source:
		# https://raw.githubusercontent.com/nextcloud/docker/refs/heads/master/.examples/docker-compose/insecure/mariadb/fpm/web/nginx.conf
		#
		# HSTS settings
		# WARNING: Only add the preload option once you read about
		# the consequences in https://hstspreload.org/. This option
		# will add the domain to a hardcoded list that is shipped
		# in all major browsers and getting removed from this list
		# could take several months.
		# Strict-Transport-Security "max-age=15768000; includeSubDomains; preload;"
		Strict-Transport-Security: "max-age=31536000; includeSubDomains;"

		# HTTP response headers borrowed from Nextcloud `.htaccess`
		Referrer-Policy no-referrer
		X-Content-Type-Options nosniff
		X-Download-Options noopen
		X-Frame-Options SAMEORIGIN
		X-Permitted-Cross-Domain-Policies none
		X-Robots-Tag "noindex,nofollow"
		X-XSS-Protection "1; mode=block"

		Permissions-Policy "accelerometer=(), ambient-light-sensor=(), autoplay=(), battery=(), camera=(), cross-origin-isolated=(), display-capture=(), document-domain=(), encrypted-media=(), execution-while-not-rendered=(), execution-while-out-of-viewport=(), fullscreen=(), geolocation=(), gyroscope=(), keyboard-map=(), magnetometer=(), microphone=(), midi=(), navigation-override=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(), usb=(), web-share=(), xr-spatial-tracking=()"
	}

	# Path to the root of your installation
	root * /var/www/html

	handle_path /push/* {
		reverse_proxy unix//var/run/notify-push/notify-push.sock
	}

	route {
		# Rule borrowed from `.htaccess` to handle Microsoft DAV clients
		@msftdavclient {
			header User-Agent DavClnt*
			path /
		}
		redir @msftdavclient /remote.php/webdav/ temporary

		route /robots.txt {
			log_skip
			file_server
		}

		# Add exception for `/.well-known` so that clients can still access it
		# despite the existence of the `error @internal 404` rule which would
		# otherwise handle requests for `/.well-known` below
		route /.well-known/* {
			redir /.well-known/carddav /remote.php/dav/ permanent
			redir /.well-known/caldav /remote.php/dav/ permanent

			@well-known-static path \
 /.well-known/acme-challenge /.well-known/acme-challenge/* \
 /.well-known/pki-validation /.well-known/pki-validation/*
			route @well-known-static {
				try_files {path} {path}/ =404
				file_server
			}

			redir * /index.php{path} permanent
		}

		@internal path \
 /build /build/* \
 /tests /tests/* \
 /config /config/* \
 /lib /lib/* \
 /3rdparty /3rdparty/* \
 /templates /templates/* \
 /data /data/* \
 \
 /.* \
 /autotest* \
 /occ* \
 /issue* \
 /indie* \
 /db_* \
 /console*
		error @internal 404

		@assets {
			path *.css *.js *.mjs *.js.map *.svg *.gif *.png *.jpg *.jpeg *.ico *.wasm *.tflite *.map *.wasm2
			file {path} # Only if requested file exists on disk, otherwise /index.php will take care of it
		}
		route @assets {
			header /* Cache-Control "max-age=15552000" # Cache-Control policy borrowed from `.htaccess`
			header /*.woff2 Cache-Control "max-age=604800" # Cache-Control policy borrowed from `.htaccess`
			log_skip # Optional: Don't log access to assets
			file_server {
				precompressed gzip
			}
		}

		# Rule borrowed from `.htaccess`
		redir /remote/* /remote.php{path} permanent

		# Serve found static files, continuing to the PHP default handler below if not found
		try_files {path} {path}/
		@notphpordir not path /*.php /*.php/* / /*/
		file_server @notphpordir {
			pass_thru
		}

		# Required for legacy support
		#
		# Rewrites all other requests to be prepended by “/index.php” unless they match a known-valid PHP file path.
		@notlegacy {
			path *.php *.php/
			not path /index*
			not path /remote*
			not path /public*
			not path /cron*
			not path /core/ajax/update*
			not path /status*
			not path /ocs/v1*
			not path /ocs/v2*
			not path /ocs-provider/*
			not path /updater/*
			not path */richdocumentscode/proxy*
		}
		rewrite @notlegacy /index.php{uri}

		# Let everything else be handled by the PHP-FPM component
		php_fastcgi unix+h2c//var/run/nextcloud/php-fpm.sock {
			env modHeadersAvailable true # Avoid sending the security headers twice
			env front_controller_active true # Enable pretty urls
		}
	}
}

5. Links to relevant resources:

Are you trying to have HTTP/2 for the clients or between Caddy and Nextcloud? If the latter, why?

For the former, Caddy does that automatically for you without any work.

Interesting. So does that mean the "proto":"HTTP/1.1" shown in Caddy logs as well as Nextcloud itself is just cosmetic?

Not really, but it depends. Your current config disables http/2, so I can’t trust those logs. You also might be seeing the logs between Caddy and Nextcloud, which is http/1.1. Your concern should be with the connection between the client and Caddy, which will be http/2 if the client supports it (and you didn’t mess with Caddy).

{
	log {
    output file /data/access.log
    level DEBUG
	}
}


:80 {
	request_body {
		max_size {$PHP_UPLOAD_LIMIT}
	}

	# Enable gzip but do not remove ETag headers
	encode {
		zstd
		gzip 4

		minimum_length 256

		match {
			header Content-Type application/atom+xml
			header Content-Type application/javascript
			header Content-Type application/json
			header Content-Type application/ld+json
			header Content-Type application/manifest+json
			header Content-Type application/rss+xml
			header Content-Type application/vnd.geo+json
			header Content-Type application/vnd.ms-fontobject
			header Content-Type application/wasm
			header Content-Type application/x-font-ttf
			header Content-Type application/x-web-app-manifest+json
			header Content-Type application/xhtml+xml
			header Content-Type application/xml
			header Content-Type font/opentype
			header Content-Type image/bmp
			header Content-Type image/svg+xml
			header Content-Type image/x-icon
			header Content-Type text/cache-manifest
			header Content-Type text/css
			header Content-Type text/plain
			header Content-Type text/vcard
			header Content-Type text/vnd.rim.location.xloc
			header Content-Type text/vtt
			header Content-Type text/x-component
			header Content-Type text/x-cross-domain-policy
		}
	}

	header {
		# Based on following source:
		# https://raw.githubusercontent.com/nextcloud/docker/refs/heads/master/.examples/docker-compose/insecure/mariadb/fpm/web/nginx.conf
		#
		# HSTS settings
		# WARNING: Only add the preload option once you read about
		# the consequences in https://hstspreload.org/. This option
		# will add the domain to a hardcoded list that is shipped
		# in all major browsers and getting removed from this list
		# could take several months.
		# Strict-Transport-Security "max-age=15768000; includeSubDomains; preload;"
		Strict-Transport-Security: "max-age=31536000; includeSubDomains;"

		# HTTP response headers borrowed from Nextcloud `.htaccess`
		Referrer-Policy no-referrer
		X-Content-Type-Options nosniff
		X-Download-Options noopen
		X-Frame-Options SAMEORIGIN
		X-Permitted-Cross-Domain-Policies none
		X-Robots-Tag "noindex,nofollow"
		X-XSS-Protection "1; mode=block"

		Permissions-Policy "accelerometer=(), ambient-light-sensor=(), autoplay=(), battery=(), camera=(), cross-origin-isolated=(), display-capture=(), document-domain=(), encrypted-media=(), execution-while-not-rendered=(), execution-while-out-of-viewport=(), fullscreen=(), geolocation=(), gyroscope=(), keyboard-map=(), magnetometer=(), microphone=(), midi=(), navigation-override=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(), usb=(), web-share=(), xr-spatial-tracking=()"
	}

	# Path to the root of your installation
	root * /var/www/html

	handle_path /push/* {
		reverse_proxy unix//var/run/notify-push/notify-push.sock
	}

	route {
		# Rule borrowed from `.htaccess` to handle Microsoft DAV clients
		@msftdavclient {
			header User-Agent DavClnt*
			path /
		}
		redir @msftdavclient /remote.php/webdav/ temporary

		route /robots.txt {
			log_skip
			file_server
		}

		# Add exception for `/.well-known` so that clients can still access it
		# despite the existence of the `error @internal 404` rule which would
		# otherwise handle requests for `/.well-known` below
		route /.well-known/* {
			redir /.well-known/carddav /remote.php/dav/ permanent
			redir /.well-known/caldav /remote.php/dav/ permanent

			@well-known-static path \
 /.well-known/acme-challenge /.well-known/acme-challenge/* \
 /.well-known/pki-validation /.well-known/pki-validation/*
			route @well-known-static {
				try_files {path} {path}/ =404
				file_server
			}

			redir * /index.php{path} permanent
		}

		@internal path \
 /build /build/* \
 /tests /tests/* \
 /config /config/* \
 /lib /lib/* \
 /3rdparty /3rdparty/* \
 /templates /templates/* \
 /data /data/* \
 \
 /.* \
 /autotest* \
 /occ* \
 /issue* \
 /indie* \
 /db_* \
 /console*
		error @internal 404

		@assets {
			path *.css *.js *.mjs *.js.map *.svg *.gif *.png *.jpg *.jpeg *.ico *.wasm *.tflite *.map *.wasm2
			file {path} # Only if requested file exists on disk, otherwise /index.php will take care of it
		}
		route @assets {
			header /* Cache-Control "max-age=15552000" # Cache-Control policy borrowed from `.htaccess`
			header /*.woff2 Cache-Control "max-age=604800" # Cache-Control policy borrowed from `.htaccess`
			log_skip # Optional: Don't log access to assets
			file_server {
				precompressed gzip
			}
		}

		# Rule borrowed from `.htaccess`
		redir /remote/* /remote.php{path} permanent

		# Serve found static files, continuing to the PHP default handler below if not found
		try_files {path} {path}/
		@notphpordir not path /*.php /*.php/* / /*/
		file_server @notphpordir {
			pass_thru
		}

		# Required for legacy support
		#
		# Rewrites all other requests to be prepended by “/index.php” unless they match a known-valid PHP file path.
		@notlegacy {
			path *.php *.php/
			not path /index*
			not path /remote*
			not path /public*
			not path /cron*
			not path /core/ajax/update*
			not path /status*
			not path /ocs/v1*
			not path /ocs/v2*
			not path /ocs-provider/*
			not path /updater/*
			not path */richdocumentscode/proxy*
		}
		rewrite @notlegacy /index.php{uri}

		# Let everything else be handled by the PHP-FPM component
		php_fastcgi unix//var/run/nextcloud/php-fpm.sock {
			env modHeadersAvailable true # Avoid sending the security headers twice
			env front_controller_active true # Enable pretty urls
		}
	}
}

Everything I test reports HTTP/1.1, including inspecting browser traffic. There are these two lines in particular in Caddy’s logs, which led me down the route of trying internal TLS:

{"level":"warn","ts":1743620571.2846594,"logger":"http","msg":"HTTP/2 skipped because it requires TLS","network":"tcp","addr":":80"}
{"level":"warn","ts":1743620571.2846692,"logger":"http","msg":"HTTP/3 skipped because it requires TLS","network":"tcp","addr":":80"}

Thanks for the responses, by the way.

Why this?

HTTP/2 requires HTTPS. You’re disabling it.

That’s the part I was experimenting with. :443 by itself doesn’t work, so I added the needed IPs and set tls internal for cert gen. This does work for local connections, but when I try TSDProxy I get a bad certificate error:

[49195,49199,49196,49200,52393,52392,49161,49171,49162,49172,4865,4866,4867],"ServerName":"","SupportedCurves":[4588,29,23,24,25],"SupportedPoints":"AA==","SignatureSchemes":[2052,1027,2055,2053,2054,1025,1281,1537,1283,1539,513,515],"SupportedProtos":null,"SupportedVersions":[772,771],"RemoteAddr":{"IP":"174.53.0.1","Port":43632,"Zone":""},"LocalAddr":{"IP":"174.53.0.10","Port":443,"Zone":""}}}}
{"level":"debug","ts":1743623194.6194956,"logger":"tls.handshake","msg":"choosing certificate","identifier":"174.53.0.10","num_choices":1}
{"level":"debug","ts":1743623194.6195173,"logger":"tls.handshake","msg":"default certificate selection results","identifier":"174.53.0.10","subjects":["174.53.0.10"],"managed":true,"issuer_key":"local","hash":"edba44472739310cc2927918d4e5555805d81d9004ffa0f16e6e5e4434acf2b1"}
{"level":"debug","ts":1743623194.6195319,"logger":"tls.handshake","msg":"matched certificate in cache","remote_ip":"174.53.0.1","remote_port":"43632","subjects":["174.53.0.10"],"managed":true,"expiration":1743665954,"hash":"edba44472739310cc2927918d4e5555805d81d9004ffa0f16e6e5e4434acf2b1"}
{"level":"debug","ts":1743623194.6200137,"logger":"http.stdlib","msg":"http: TLS handshake error from 174.53.0.1:43632: remote error: tls: bad certificate"}

There’s an option to disable TLS verification in TSDProxy, but for whatever strange reason the webpage won’t load whatsoever even though TSDProxy shows code 200. Caddy doesn’t show anything in its logs with that set.

So you’re not connecting to Caddy directly, rather through Tailscale (TSDProxy)? If yes, then Caddy isn’t the thing you need to configure and fix. It’s probably Tailscale because they receive it through their network.

I suppose, though, fully-fledged TLS on the file server itself isn’t really a necessity at all. I’m mainly just curious about getting auto-upgrade to h2c working, though I’m no expert on protocols or if that’s even a possibility (HTTPS proxy routing to an internal HTTP server, retaining HTTP/2)