Skip to content

Persist the self-signed certificate with proper SANs, and serve HTTPS in front of an HTTP backend - #1

Merged
byjg merged 8 commits into
masterfrom
feature/tls-selfsigned-and-listeners
Sep 17, 2026
Merged

byjg merged 8 commits into
masterfrom
feature/tls-selfsigned-and-listeners

Conversation

@byjg

@byjg byjg commented Sep 17, 2026

Copy link
Copy Markdown
Owner

What and why

HTTPS worked, but not in a way anything could actually trust or keep: the generated certificate
carried only a CommonName, so it failed hostname verification in every modern client even when it
was explicitly trusted, and it was regenerated on every start. A backend that speaks only HTTP also
could not be put behind it, because / was rejected as a proxy prefix.

Certificates

  • The self-signed certificate now carries SubjectAltName entries — localhost, 127.0.0.1,
    ::1, the hostname and the address the server listens on — so curl --cacert and a trusted
    browser exception work instead of only -k.
  • It is saved to the certificate directory and reused until it expires (30 day margin) or stops
    covering the requested hosts. When the directory cannot be written it stays in memory, as before.
  • --tls-cert-dir defaults to /certs for root and ~/.static-httpserver/certs otherwise, so an
    unprivileged process has somewhere to write. The image pins TLS_CERT_DIR=/certs and ships the
    directory owned by the runtime user.
  • --tls-cert-file / --tls-key-file point at files that do not follow the cert.pem/key.pem
    convention. The README documented --tls-cert-dir /etc/letsencrypt/live/... for exactly that
    case, where it silently fell back to a self-signed certificate because certbot writes
    privkey.pem, not key.pem.
  • --tls-selfsigned-hosts adds names the server cannot discover, such as a public DNS name.

Listeners and proxying

  • --bind restricts both listeners to one address; --only-https disables HTTP even when a port is
    set, which is the only way to get an HTTPS-only container since the image sets PORT=8080.
  • / is now a valid proxy prefix — it matches every path and strips nothing — so static-httpserver
    can terminate TLS in front of a plain-HTTP backend. Routes are matched most specific first, so a
    catch-all never shadows a specific route.
  • Proxied requests carry X-Forwarded-Proto and X-Forwarded-Host. Without them a backend behind
    the TLS terminator reports http, drops secure cookies and builds http:// redirects. Values
    from an upstream proxy are preserved.
  • examples/serve-https.sh wraps the local-development case (npm run serve-https).

Breaking change: the built-in endpoints moved from /health and /headers to /_health and
/_headers, so an application's own /health is proxied through untouched instead of being
shadowed by ours. --health-path / --headers-path move them. The chart's probes follow
parameters.healthPath; probes, uptime monitors and load balancer checks defined elsewhere have to
be pointed at /_health, or the old path restored with --health-path /health.

Checklist

  • Opened against the right branch -- the default branch, or for a PHP
    component see Contributing to PHP components
  • Tests added or updated, and the checks pass locally
  • Documentation updated in this repository (README.md or docs/) if
    behaviour changed
  • One fix or one feature -- this carries two related changes: the certificate handling and the
    proxy/endpoint work. Happy to split it into two PRs if you prefer.

--bind (env BIND_ADDRESS, default 0.0.0.0) sets the address both listeners
bind to, so the server can be restricted to a single interface instead of
always accepting connections on every one of them. A value that is not a
valid IP address is rejected at startup.

--only-https (env ONLY_HTTPS) disables the HTTP listener even when a port is
set. Without it there is no way to get an HTTPS-only container, because the
image sets PORT=8080 in its environment.
The generated certificate carried only a CommonName, so it failed hostname
verification in every client that has required SubjectAltName for years, even
when the certificate was explicitly trusted. It was also recreated on every
start, so a browser exception or a pinned copy never survived a restart.

It now carries SubjectAltName entries for localhost, 127.0.0.1, ::1, the
machine hostname and the address it listens on -- the bind address when one is
given, every routable address of the machine when binding to 0.0.0.0 -- and is
saved to the certificate directory as selfsigned-cert.pem / selfsigned-key.pem.
The saved pair is reused until it expires (with a 30 day margin) or stops
covering the requested hosts. When the directory cannot be written the
certificate is kept in memory, as it was before.

--tls-selfsigned-hosts adds names the server cannot discover by itself, such
as a public DNS name.

--tls-cert-file and --tls-key-file point at a certificate whose file names do
not follow the cert.pem/key.pem convention, such as the fullchain.pem and
privkey.pem written by Let's Encrypt. The README documented --tls-cert-dir for
exactly that directory, where it silently fell back to a self-signed
certificate because key.pem does not exist there. Unlike the directory, a file
given explicitly is fatal when it cannot be read.

--tls-cert-dir now defaults to /certs when running as root and to
~/.static-httpserver/certs otherwise, so an unprivileged process has somewhere
to write. The image pins TLS_CERT_DIR=/certs and ships that directory owned by
the runtime user, which a named volume inherits; without it the process cannot
create /certs at all.
go build without -o leaves static-httpserver next to the sources; bin/ was
already ignored, this covers the plain build too.
A backend that speaks only HTTP could not be put behind the HTTPS listener:
"/" was rejected as a proxy prefix, so the whole site could never be handed to
it. "/" is now the catch-all prefix, matching every path and stripping
nothing, and routes are matched most specific first, so a catch-all never
shadows a specific route regardless of the order they were given in. /health
is still answered locally, so it keeps working as a probe.

Proxied requests now also carry X-Forwarded-Proto and X-Forwarded-Host. TLS is
terminated here, so without them a backend cannot tell that the client spoke
HTTPS: Express reports req.protocol as http, drops secure cookies and builds
http:// redirects. Values coming from an upstream proxy are left untouched.
/health sits on a path applications commonly serve themselves, and the
catch-all proxy route turned that into a real collision: the server answered
/health locally, so the backend behind it could never be reached there.

The built-in endpoints are now /_health and /_headers, leaving an application's
own /health to be proxied through untouched, and --health-path /
--headers-path (env HEALTH_PATH / HEADERS_PATH) move them when even those
names are taken. The parking page fetches whatever the headers path is set to,
and the chart points its probes at parameters.healthPath.

The health endpoint changes with this: probes, uptime monitors and load
balancer checks defined outside the chart have to follow it to /_health, or
restore the previous path with --health-path /health.
Putting HTTPS in front of a local app took a hand-written wrapper every time,
and the obvious one -- backgrounding the app and exec'ing the server -- leaves
the app orphaned when the server stops.

examples/serve-https.sh starts the app, runs static-httpserver in front of it
as a TLS terminator and forwards the stop signal to both. The command and the
ports are environment variables, so the same script fits any stack, and it is
meant to be copied into a project and called from package.json as
"serve-https".
The workflow only built the Docker image, which compiles the binary but never
runs a test, so nothing guarded the behaviour the tests cover. A Test job now
runs make lint and make test, and the image build waits for it.

Go is pinned from the go directive in go.mod. The module has no dependencies
and therefore no go.sum, so the setup-go cache is turned off: with it enabled
the step fails for the missing lock file.
The chart templates changed with the new health endpoint: the probes follow
parameters.healthPath and default to /_health. Chart repositories key on the
version, so publishing the change needs a new one.
@byjg
byjg merged commit a4dadaa into master Sep 17, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant