Problem
Any stack that will ship as independent HTTPS subdomains - an API, a server-rendered site, a client-rendered app, a CDN for assets - is usually developed as localhost:4000, :3000, :5173. That hides an entire class of bugs until deploy. Header links point at production URLs or at port numbers that do not exist in production. Nothing exercises TLS the way the live sites do. Cross-domain behaviour - cookies, CORS, mixed content - simply does not exist when everything lives on localhost:PORT.
The prototype in prj--local-traefik-dev-setup exists to make that topology local and cheap: four public hosts, one machine, ports 80 and 443 only.
Approach
I treated local development like a miniature version of a production domain layout. Traefik terminates TLS on ports 80 and 443, redirects HTTP to HTTPS, and routes by Host header to the right container - subdomain-for-subdomain instead of port-for-port.
The POC is six containers on one Docker Compose network:
- Postgres - a
productscatalogue with schema and seed data on first volume create. - BE API - Express +
pg, CRUD on/api/products, CORS allow-listed to the site and app origins. - SSR site - Express, renders HTML from product JSON fetched server-to-server over the Docker network (
http://api:4000). - SPA - Vite + React, fetches the same JSON from the browser through Traefik (
https://local.api.traefik-poc.local). - Static asset server - nginx, serving product images at
https://local.assets.traefik-poc.local. - Traefik - reverse proxy, TLS terminator, dashboard on
:8080.
The interesting split is how data is fetched. The SSR site never leaves the Docker network to talk to the API. The SPA always does, as a genuine cross-origin HTTPS request. Images on both surfaces load from a third subdomain. That is the path localhost:PORT never takes.
- One local subdomain per public service -
local.api.traefik-poc.local,local.site.traefik-poc.local,local.app.traefik-poc.local, andlocal.assets.traefik-poc.local, mapped via/etc/hosts. - mkcert issues browser-trusted certificates for those names so HTTPS looks and behaves like production instead of throwing certificate warnings or falling back to plain HTTP.
- Per-service Dockerfiles and a shared Compose file; Traefik discovers backends from Docker labels (
Host()rules,websecure, TLS). - Cross-site navigation via env vars (
SITE_URL,APP_URL,VITE_API_URL, …) so the shared header links sibling subdomains instead of ports. - HMR over WSS through Traefik - the Vite SPA is configured with
allowedHostsand explicit HMR host/port/protocol, since secure-context WebSocket upgrades behave differently than plain-HTTP HMR.
Browser ──► Traefik (:443, TLS)
├── local.api.traefik-poc.local → api (:4000)
├── local.site.traefik-poc.local → ssr-site (:3000)
├── local.app.traefik-poc.local → spa-app (:5173)
├── local.assets.traefik-poc.local → assets (:80)
└── HTTP :80 → HTTPS redirect
Why mimic production domains instead of just using ports
Running services on localhost:4000, :3000, :5173 hides bugs that only exist once those services are split across real, independent subdomains:
- Cross-site navigation - header links to sibling services; on plain ports those links are either hardcoded to production (wrong in dev) or silently untested.
- Cookie and storage scoping - anything scoped per-origin (cookies,
localStorage) behaves differently across sibling subdomains than acrosslocalhostports. - Mixed content and CSP - production is HTTPS-only; testing over plain HTTP on
localhostmasks mixed-content and content-security-policy failures until they surface in production. - CORS - the SPA calling
local.api.traefik-poc.localfromlocal.app.traefik-poc.localis genuinely cross-origin;localhost:PORTtolocalhost:PORTis a different (and more permissive) browser security boundary. - HMR reliability - Vite's HMR websocket has to survive a TLS-terminating reverse proxy, which is a meaningfully different code path than a bare local dev server.
Matching the subdomain shape, not just the port numbers, means these issues get caught while iterating locally instead of after a deploy.
Outcome
A single make compose_up boots the prototype. Developers hit real domain names on standard ports, the SSR path and the SPA path exercise different network boundaries against the same API, and HTTPS behaviour matches production closely enough to catch mixed-content, cookie-scope, and CORS issues early. The setup guide lives in _docs/local-dev-setup.md.
Reference implementation: Personal Portfolio v3
This repository is the generic prototype. The same pattern - Traefik, mkcert, /etc/hosts, Host-based routing, HMR over WSS - is the local-dev counterpart to the real domain layout of Personal Portfolio v3, a TypeScript pnpm monorepo that ships four frontends from one SQLite artifact: local.paulserban.eu, local.blog.paulserban.eu, local.quiz.paulserban.eu, and local.news-feed.paulserban.eu mapping 1:1 to their production subdomains.
Where the POC uses a products API, an Express SSR site, a React SPA, and nginx assets, v3 applies the identical reverse-proxy shape to Astro portfolio, Astro blog, Astro news-feed, and a Vite/React quiz PWA, with a shared local.base.Dockerfile and per-app local.Dockerfile.