paulserban.eu

Portfolio Edition

Paul Serban

AI & full-stack engineer · agentic platforms · distributed systems

← Back to portfolio

Local Traefik Dev Stack

Partial system - core mechanism proven in code

Production-like HTTPS across sibling subdomains on one machine

Role: Solo builder

  • #docker
  • #express
  • #mkcert
  • #nginx
  • #postgres

Problem

Multi-service stacks developed on localhost:PORT never exercise the HTTPS, CORS, cookie-scope, or cross-origin behaviour that production subdomains actually have.

Approach

A generic Traefik + mkcert POC - Postgres, Express API, Express SSR, React SPA, nginx assets - with Host-based routing that mirrors a production subdomain topology one-for-one.

Outcome

One `make compose_up` boots the prototype at local.*.traefik-poc.local on ports 80/443; the same pattern is the local-dev stack behind Personal Portfolio v3.

services
6
public_hosts
4
ports_exposed
80 + 443
cert_trust
mkcert
View code

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:

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.

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:

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.