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.

Request Path Through the Local Proxy A browser request travels to the reverse proxy on ports 80 and 443, which terminates TLS and forwards to the right container over the Compose network. Request Path Through the Local Proxy Browser https://api.localhost Host ports 80/443 the only published ports Traefik TLS termination and routing Compose network service DNS: api, web, mail Containers plain HTTP on internal ports
One proxy owns ports 80 and 443; every service behind it stays on an internal port and is reached by hostname.

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). The include and watch features used elsewhere on this site need 2.20 or later, and the healthcheck-gated depends_on conditions need a v2 engine.
  • mkcert 1.4.4+ installed on the host (brew install mkcert, sudo apt install mkcert, or choco install mkcert / scoop install mkcert on Windows). mkcert needs certutil (package libnss3-tools on 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:LISTEN before 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.

  1. Install the CA once per laptop. This writes rootCA.pem and rootCA-key.pem into the directory printed by mkcert -CAROOT and registers the CA with the OS store.
  2. Generate one wildcard certificate that covers every service hostname the stack uses.
  3. 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.

How mkcert Makes Certificates Trusted mkcert creates a local CA, installs it into the trust stores, signs a wildcard leaf certificate and the proxy serves it to the browser. How mkcert Makes Certificates Trusted mkcert -install create local CA OS trust store CA registered Leaf certificate *.localhost signed Proxy serves it browser verifies
Trust flows from the CA installed once per machine; leaf certificates can be regenerated freely.

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
  1. Save the second block as proxy/tls.yaml and the first as part of compose.yaml.
  2. Start the proxy and one service with docker compose up -d proxy api.
  3. 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.

Reading the First curl Against the Proxy A decision diagram mapping curl results against the proxy to their most likely cause. Reading the First curl Against the Proxy What does curl return? 404 page not found router missing or not enabled 502 Bad Gateway upstream port or network wrong SSL verify error CA not trusted by this client
The HTTP status from the proxy narrows the fault to routing, upstream or certificate trust.

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
Who Resolves *.localhost to Loopback Table showing which clients resolve localhost subdomains to 127.0.0.1 and which need a fallback. Who Resolves *.localhost to Loopback Client Resolves Fallback Chrome, Firefox, Safari yes none needed curl 7.78+ yes --resolve Linux glibc + resolved yes dnsmasq Inside a container no network alias
Browsers are consistent; CLI tools and in-container clients need the fallbacks in this section.

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"]
  1. Export CAROOT from the host before docker compose up so the path interpolates.
  2. Mount only rootCA.pem — the public certificate — never the key.
  3. 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.

Disabling Verification vs Trusting the CA Comparison of turning off TLS verification against mounting the local CA into containers. Disabling Verification vs Trusting the CA Disable verification Mount the local CA NODE_TLS_REJECT_UNAUTHORIZED=0 NODE_EXTRA_CA_CERTS hides real cert bugs catches real cert bugs leaks into prod configs nothing to remove later differs per language one PEM for every runtime
Mounting the public CA certificate keeps production TLS code paths exercised locally.

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.

Hop-by-Hop Proxy Diagnosis Five ordered checks from name resolution to the upstream health endpoint. Hop-by-Hop Proxy Diagnosis 1 — name resolves to 127.0.0.1 resolver 2 — proxy accepts TLS on 443 certificate 3 — router rule matches Host labels 4 — upstream container healthy compose ps 5 — proxy reaches upstream port network
Stop at the first hop that fails; everything after it is noise.

Platform caveats

macOS (Docker Desktop): ports 80 and 443 bind without root because Docker Desktop's VM owns the listener. If mkcert -install succeeded but Firefox still warns, Firefox is using its own NSS store — run mkcert -install again 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 -install on the Windows side (PowerShell), then point WSL at that CA with export CAROOT="/mnt/c/Users/$USER/AppData/Local/mkcert" before generating leaf certificates.

Apple Silicon (ARM64): the official traefik:v3 image 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 with docker image inspect --format '{{.Architecture}}'.

Linux: binding ports below 1024 with rootless Docker fails with permission denied. Either set net.ipv4.ip_unprivileged_port_start=80 via sysctl or publish 8443:443 and 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.

Every guide in this topic