Local HTTPS and Reverse Proxies for Compose Stacks
Plain http://localhost:3000 stops being good enough the moment the application depends on anything the browser treats as security-sensitive: Secure cookies, OAuth redirect URIs, service workers, SameSite=None, WebAuthn, or a frontend and API that must share a parent domain. The result is a fork in behaviour — the code paths that run in production over HTTPS never run on a laptop — and a steady stream of "login works in staging but not locally" tickets. This topic, part of containerized local environments with Docker Compose, shows how to put a reverse proxy in front of a Compose stack, route each service by hostname, and serve it all over HTTPS with certificates that the browser, the CLI tools and the containers themselves trust.
The target state is simple to describe. A developer runs docker compose up, opens https://app.localhost, and the browser shows a padlock with no warning. The API lives at https://api.localhost, the mail catcher at https://mail.localhost, and a service calling another service over HTTPS inside the Compose network verifies the certificate instead of setting NODE_TLS_REJECT_UNAUTHORIZED=0. Nothing about that requires a public domain, a DNS server or an internet connection — only a locally generated certificate authority, a proxy that terminates TLS, and a handful of labels on each service.
Prerequisites
Before following the sections below, confirm the pieces the setup depends on. Every command here was written against these versions; older releases work in most cases but some label syntax differs.
- Docker Engine 24+ with Compose v2.20+ (
docker compose version). Theincludeandwatchfeatures used elsewhere on this site need 2.20 or later, and the healthcheck-gateddepends_onconditions need a v2 engine. - mkcert 1.4.4+ installed on the host (
brew install mkcert,sudo apt install mkcert, orchoco install mkcert/scoop install mkcerton Windows). mkcert needscertutil(packagelibnss3-toolson Debian/Ubuntu) if you want Firefox and Chromium's NSS store to trust the CA on Linux. - Free host ports 80 and 443. On macOS, nothing binds them by default; on Linux, an Apache or nginx package left running will. Check with
sudo lsof -iTCP:443 -sTCP:LISTENbefore starting. - Permission to install a root CA into the operating system trust store. On a managed corporate laptop this may require an IT ticket; the corporate proxy and TLS interception guide covers the case where the machine already trusts a company CA.
A quick preflight that prints each prerequisite and fails loudly on the first missing one keeps these requirements out of tribal knowledge:
#!/usr/bin/env bash
set -euo pipefail
docker compose version --short | awk -F. '{ if ($1 < 2 || ($1 == 2 && $2 < 20)) { print "compose too old: " $0; exit 1 } }'
command -v mkcert >/dev/null || { echo "mkcert missing"; exit 1; }
for port in 80 443; do
if lsof -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1; then
echo "port $port already in use:"; lsof -iTCP:"$port" -sTCP:LISTEN; exit 1
fi
done
echo "prerequisites ok"
A local certificate authority with mkcert
Self-signed certificates generated per service are the classic trap: each one needs its own browser exception, curl needs -k, and every container that calls another must disable verification. A local certificate authority solves all three at once. mkcert creates a CA key pair on the machine, installs the CA certificate into the system and browser trust stores, and then signs leaf certificates for whatever hostnames you ask for. Because the browser trusts the CA, it trusts every certificate signed by it — no per-site exceptions.
- Install the CA once per laptop. This writes
rootCA.pemandrootCA-key.peminto the directory printed bymkcert -CAROOTand registers the CA with the OS store. - Generate one wildcard certificate that covers every service hostname the stack uses.
- Store the certificate files in a git-ignored directory the proxy can mount.
#!/usr/bin/env bash
set -euo pipefail
mkcert -install
mkdir -p .certs
mkcert -cert-file .certs/local.pem -key-file .certs/local-key.pem \
"localhost" "*.localhost" "127.0.0.1" "::1"
grep -qxF '.certs/' .gitignore || echo '.certs/' >> .gitignore
openssl x509 -in .certs/local.pem -noout -subject -ext subjectAltName
The final openssl line is the drift diagnostic for this section: it prints the subject alternative names baked into the certificate. When a new service is added under a hostname the certificate does not cover, this is where the gap shows. The mkcert walkthrough covers per-browser trust, certificate expiry and regenerating after a hostname change.
Two rules keep this safe. First, the CA private key never leaves the laptop and is never committed: anyone holding rootCA-key.pem can mint certificates your browser trusts for any domain, including your bank's. Second, each developer runs mkcert -install themselves; sharing a single team CA through git trades a small convenience for a real attack surface.
Terminating TLS with a Traefik reverse proxy
A reverse proxy gives the stack one front door. Instead of publishing 3000:3000, 8080:8080 and 5173:5173 and memorising which port is which service, every service joins a shared network and the proxy routes by hostname. Traefik is a good fit for Compose because it reads routing rules from container labels through the Docker socket — adding a service to the proxy is a matter of adding labels next to the service definition, not editing a central config file.
services:
proxy:
image: traefik:v3.1
command:
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --providers.file.filename=/etc/traefik/tls.yaml
- --entrypoints.web.address=:80
- --entrypoints.web.http.redirections.entrypoint.to=websecure
- --entrypoints.websecure.address=:443
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./.certs:/certs:ro
- ./proxy/tls.yaml:/etc/traefik/tls.yaml:ro
api:
build: ./api
labels:
- traefik.enable=true
- traefik.http.routers.api.rule=Host(`api.localhost`)
- traefik.http.routers.api.entrypoints=websecure
- traefik.http.routers.api.tls=true
- traefik.http.services.api.loadbalancer.server.port=8080
The TLS file provider tells Traefik which certificate to serve by default:
tls:
certificates:
- certFile: /certs/local.pem
keyFile: /certs/local-key.pem
stores:
default:
defaultCertificate:
certFile: /certs/local.pem
keyFile: /certs/local-key.pem
- Save the second block as
proxy/tls.yamland the first as part ofcompose.yaml. - Start the proxy and one service with
docker compose up -d proxy api. - Confirm the route is registered and the certificate verifies without
-k.
#!/usr/bin/env bash
set -euo pipefail
curl -sS -o /dev/null -w '%{http_code} %{ssl_verify_result}\n' https://api.localhost/health
docker compose logs proxy --since 2m | grep -iE 'error|unable' || echo "no proxy errors"
A 200 0 response means the request was routed and the certificate chain verified (0 is OpenSSL's "ok"). A 404 means Traefik is running but no router matched the host — almost always a missing traefik.enable=true label, since exposedbydefault=false hides unlabelled containers. The Traefik label routing guide goes through path-based routing, middlewares and the dashboard.
Hostnames that resolve without hosts-file edits
Routing by hostname only works if the hostname resolves to the proxy. Editing /etc/hosts for every new service is exactly the kind of manual, per-machine step that drifts. The cleaner option is the .localhost top-level domain: RFC 6761 reserves it for loopback, and Chromium, Firefox and Safari resolve any *.localhost name to 127.0.0.1 internally without consulting DNS at all.
The catch is that command-line tools do not all follow the browser. curl has resolved *.localhost to loopback since 7.78; glibc's resolver on most Linux distributions resolves it through systemd-resolved; but older macOS resolvers, nslookup, and language runtimes that do their own DNS may not. A small diagnostic makes the difference visible on each machine:
#!/usr/bin/env bash
set -euo pipefail
for name in app.localhost api.localhost; do
printf '%-16s getent: ' "$name"
getent hosts "$name" 2>/dev/null | awk '{print $1}' || echo "no answer"
printf '%-16s curl: ' "$name"
curl -sS -o /dev/null -w '%{remote_ip}\n' "https://$name/" || true
done
If a tool cannot resolve the name, there are three fixes in increasing order of effort: add explicit entries for that tool only (curl --resolve api.localhost:443:127.0.0.1), run a tiny local DNS forwarder such as dnsmasq with address=/localhost/127.0.0.1, or fall back to /etc/hosts entries generated from the Compose file so they never drift from the service list. The .localhost subdomain guide compares these, and the older local DNS routing guide covers custom domains such as *.test.
Services inside the Compose network have the opposite problem: api.localhost resolves to the container's own loopback, not to the proxy. When the web container needs to reach the API through the proxy by its public name, add a network alias on the proxy so the name resolves to it inside Docker's embedded DNS:
services:
proxy:
networks:
default:
aliases:
- api.localhost
- app.localhost
Making containers trust the local CA
Browser trust is only half the job. When the web container's server-side rendering calls https://api.localhost, or a worker calls a webhook receiver over HTTPS, the TLS client inside that container consults the container's trust store — which knows nothing about the mkcert CA on the host. The failure shows up as UNABLE_TO_VERIFY_LEAF_SIGNATURE in Node, CERTIFICATE_VERIFY_FAILED in Python, or x509: certificate signed by unknown authority in Go.
The correct fix is to give the container the CA certificate, never to disable verification. Most runtimes accept an extra CA file through an environment variable, which avoids rebuilding the image:
services:
web:
build: ./web
volumes:
- ${CAROOT:?run: export CAROOT="$(mkcert -CAROOT)"}/rootCA.pem:/usr/local/share/ca-certificates/mkcert.crt:ro
environment:
NODE_EXTRA_CA_CERTS: /usr/local/share/ca-certificates/mkcert.crt
REQUESTS_CA_BUNDLE: /etc/ssl/certs/ca-certificates.crt
SSL_CERT_FILE: /etc/ssl/certs/ca-certificates.crt
entrypoint: ["/bin/sh", "-c", "update-ca-certificates >/dev/null 2>&1 || true; exec \"$$@\"", "--"]
command: ["node", "server.js"]
- Export
CAROOTfrom the host beforedocker compose upso the path interpolates. - Mount only
rootCA.pem— the public certificate — never the key. - Verify from inside the container with the runtime's own client, not just
curl.
#!/usr/bin/env bash
set -euo pipefail
export CAROOT="$(mkcert -CAROOT)"
docker compose up -d web
docker compose exec web node -e "fetch('https://api.localhost/health').then(r => console.log(r.status))"
NODE_EXTRA_CA_CERTS is read once at process start, so a container that was already running needs a restart after the mount is added. The container certificate error guide has per-language variants for Java keystores, Go and Alpine images without update-ca-certificates.
Debugging the proxy when a route fails
With a proxy in the path, a failed request has more places to fail: the name may not resolve, the router may not match, the upstream may be on the wrong port or network, or the upstream may not be up yet. Checking each hop in order turns a vague "it doesn't load" into a precise cause in under a minute.
#!/usr/bin/env bash
set -euo pipefail
host="${1:-api.localhost}"
echo "1. resolve"; curl -sS -o /dev/null -w '%{remote_ip}\n' "https://$host/" || true
echo "2. routers"; curl -sS http://localhost:8080/api/http/routers | jq -r '.[] | "\(.name) \(.rule) \(.status)"'
echo "3. upstream"; docker compose ps --format '{{.Service}} {{.State}} {{.Health}}'
echo "4. from proxy"; docker compose exec proxy wget -qO- --timeout=3 "http://${host%%.*}:8080/health" || echo "upstream unreachable from proxy"
Step 2 needs the dashboard API enabled (--api.insecure=true on port 8080, local only). Step 4 is the most useful: it reproduces the proxy's own upstream request, so a failure there proves the problem is between proxy and container — wrong loadbalancer.server.port, a service that binds 127.0.0.1 instead of 0.0.0.0, or a service attached to a different network. The 502 Bad Gateway guide walks through each of those.
Platform caveats
macOS (Docker Desktop): ports 80 and 443 bind without root because Docker Desktop's VM owns the listener. If
mkcert -installsucceeded but Firefox still warns, Firefox is using its own NSS store — runmkcert -installagain after Firefox has been launched once so the profile exists.
WSL2: mkcert run inside WSL installs the CA into the Linux store only; Windows browsers never see it. Run
mkcert -installon the Windows side (PowerShell), then point WSL at that CA withexport CAROOT="/mnt/c/Users/$USER/AppData/Local/mkcert"before generating leaf certificates.
Apple Silicon (ARM64): the official
traefik:v3image is multi-arch, but older pinned tags and some sidecar proxies are amd64-only and run under emulation with noticeably higher TLS handshake latency. Check withdocker image inspect --format '{{.Architecture}}'.
Linux: binding ports below 1024 with rootless Docker fails with
permission denied. Either setnet.ipv4.ip_unprivileged_port_start=80via sysctl or publish8443:443and accept a port in the URL.
Rollback and recovery
Every piece of this setup is additive, so undoing it is mechanical. Remove the proxy and labels from Compose and republish the original ports; the services themselves are unchanged. To remove the local CA from the trust stores, run mkcert -uninstall — this deletes the CA from the OS, NSS and Java stores it was installed into, after which every certificate it signed stops verifying.
#!/usr/bin/env bash
set -euo pipefail
docker compose down --remove-orphans
mkcert -uninstall
rm -rf .certs
echo "removed local CA and certificates; restore original ports in compose.yaml"
If the CA key was ever committed or shared, uninstalling is not enough: delete the mkcert -CAROOT directory on every machine that received it and generate a fresh CA, because the old key can still mint trusted certificates on any machine that installed it.
Frequently Asked Questions
Is it safe to install the mkcert root CA on a developer laptop?
It is safe as long as the CA private key stays on that laptop. The CA is only trusted by the machine that created it, and mkcert never uploads anything. The risk comes from sharing rootCA-key.pem — anyone with it can issue certificates your browser trusts for any domain — so keep the mkcert -CAROOT directory out of git, backups and shared drives.
Why use *.localhost instead of a custom domain like *.test?
Browsers resolve every *.localhost name to loopback without DNS, so there is nothing to configure per machine. Custom domains such as *.test need /etc/hosts entries or a local DNS server. The trade-off is that some CLI tools and all in-container clients do not resolve *.localhost, which is why this topic adds network aliases on the proxy.
Do I need Traefik, or would nginx or Caddy work?
Any reverse proxy works. Traefik suits Compose because routing lives in labels next to each service, so adding a service never touches a central file. Caddy is a good alternative with an even shorter config and built-in local certificates; nginx works but needs a config reload whenever a service is added.
Why does the browser trust the certificate but my Node service does not?
The browser uses the OS or NSS trust store where mkcert installed the CA. The Node process inside a container uses the container's own store, which does not contain it. Mount rootCA.pem into the container and point NODE_EXTRA_CA_CERTS at it, then restart the process.
Related
- Generate and trust a wildcard development certificate
- Route each Compose service by hostname with labels
- Free a port that another process already holds
- Keep only the proxy exposed beyond loopback
Every guide in this topic
- Debugging 502 Bad Gateway From a Local Reverse ProxyTrace a 502 Bad Gateway from Traefik, Caddy or nginx to its cause: wrong upstream port, a server bound to 127.0.0.1, a split network or an upstream not yet ready.
- Fixing Certificate Errors Inside ContainersResolve unable to verify the first certificate and x509 unknown authority errors by adding the local mkcert CA to Node, Python, Go, Java and Alpine containers.
- Routing Compose Services by Hostname With TraefikReplace a wall of published ports with hostname routing: Traefik Docker labels, Host rules, upstream ports, and the 404 returned when a router is missing.
- Trusting Local HTTPS Certificates With mkcertFix NET::ERR_CERT_AUTHORITY_INVALID on local services by creating a per-laptop CA with mkcert and signing one wildcard certificate for every hostname.
- Using .localhost Subdomains Without /etc/hosts EditsResolve api.localhost and app.localhost to loopback on every machine without hosts-file edits: browser behaviour, curl, systemd-resolved, dnsmasq and Docker aliases.