If you're building a multi-tenant app, testing subdomain routing, or debugging redirect logic that depends on real hostnames, localhost:3000 will not get you there. This guide walks through setting up a VPS reverse proxy with wildcard TLS certificates so you get a genuine HTTPS domain like demo.acme-app.dev for local development, using a Root CA you generate and trust yourself. No domain purchase required. The result is a dev environment that behaves exactly like production: real subdomains, real HTTPS, real cookies, real CORS.
I run this setup for every client project that touches multi-tenant routing or complex redirect chains, including the Avtolibre marketplace and a few B2B portal builds. The moment a project needs subdomain-based tenants or locale redirects, localhost stops being useful and I reach for this stack instead.
The Problem With Testing on localhost
Most tutorials treat localhost:3000 as good enough for development. For simple CRUD apps it usually is. For anything involving subdomains, redirects, or secure cookies, it creates a gap between what you test and what actually ships.
Three specific things break down on localhost:
HSTS preloading. Browsers hardcode every .dev top-level domain to enforce HTTPS at the browser level. Plain HTTP or a self-signed certificate that isn't properly trusted gets blocked with NET::ERR_CERT_AUTHORITY_INVALID, and there's no "proceed anyway" button.
Subdomain routing. Tenant-based apps resolve tenants from the Host header (tenant-a.acme-app.dev, tenant-b.acme-app.dev). A single localhost origin can't send that header, so tenant isolation, wildcard DNS routing, and per-tenant data fetching never get exercised locally.
Port collisions. Running several apps or staging containers on one machine leads to EADDRINUSE conflicts the moment two services want the same port.
Why This Matters Beyond Cosmetics
Setting this up takes an afternoon. Here's what it buys you.
Multi-tenant subdomain routing
If tenants resolve via subdomain, you need real subdomains sending authentic Host headers to test tenant boundaries and tenant-isolated data fetching. A wildcard cert on *.acme-app.dev lets you spin up tenant-a.acme-app.dev and tenant-b.acme-app.dev and watch your middleware route them correctly, before any of that logic reaches staging.
Edge middleware, rewrites, and redirect logic
Next.js Edge Middleware and server-side rewrites depend on the exact protocol and host header matching what production sends. Domain rewrites (tenant.acme-app.dev/dashboard to /app/tenants/[tenant]/dashboard), canonical redirects, trailing slash handling, and locale detection (/sl/, /en-US/) all behave differently under http://localhost than they do under https://real-subdomain.dev. Testing this fully in dev, before it hits production, is the entire point of this setup.
Automated testing against real hostnames
This is where the setup earns its keep beyond manual QA. Playwright, Cypress, and integration test suites that assert on redirect targets, cookie domains, or Host-header-based routing need a real hostname to run against. Pointing your test runner's baseURL at https://demo.acme-app.dev instead of localhost:3000 means your CI-adjacent test suite exercises the same TLS, cookie, and redirect paths a real user hits. Tests that pass against localhost but silently skip subdomain and secure-cookie logic give you false confidence. Tests that run against a proper HTTPS domain do not.
Media, CDN, and mixed-content prevention
Next.js <Image /> and modern browsers block mixed content outright. If your page loads over HTTPS, any image or asset served over plain HTTP fails to load or throws a console warning. Testing S3 uploads, signed URLs, and media storage over https://s3.acme-app.dev catches broken SSL handshakes before they surface in front of a client.
Secure cookies and auth flags
Production auth systems set Secure, HttpOnly, and SameSite=Lax or Strict on session cookies. Browsers drop Secure cookies silently over unencrypted connections outside bare localhost, and cross-subdomain cookie sharing (domain=.acme-app.dev) needs a matching parent domain structure to test at all. Without HTTPS locally, you cannot verify this behavior until it's live.
CORS and webhook callbacks
Stripe webhooks, GitHub OAuth callbacks, and any external integration that validates the exact hostname it's calling back to need a real HTTPS endpoint. localhost cannot receive these correctly, so this class of bug traditionally only surfaces after deploy.
Architecture Overview
mermaid
sequenceDiagram
autonumber
actor Developer as macOS Developer Machine
participant Keychain as macOS Keychain Trust
participant MacHosts as Mac /etc/hosts
participant VPS as VPS Ingress (YOUR_VPS_IP:443)
participant Traefik as Traefik (TCP TLS Passthrough)
participant Nginx as Nginx (vps-dev-proxy)
participant App as Next.js Dev Server (Port 24893)
Developer->>MacHosts: Request https://demo.acme-app.dev
MacHosts->>VPS: Resolve IP YOUR_VPS_IP:443
VPS->>Traefik: Forward TCP Port 443
Note over Traefik,Nginx: Traefik inspects SNI and passes TLS through untouched
Traefik->>Nginx: TLS Passthrough to Nginx Container
Nginx->>Keychain: Serve _wildcard.acme-app.dev.pem (Signed by Root CA)
Keychain-->>Developer: Validate Certificate (HSTS Trusted)
Nginx->>App: Proxy HTTP/1.1 request to host.docker.internal:24893
App-->>Developer: Return HTTP Response (307 / 200 OK)
Traefik handles the port binding and TCP SNI routing. Nginx handles SSL termination and proxies to your app. Traefik passes TLS straight through (tls.passthrough=true) so Nginx can terminate with your own dev-signed certificate, and Traefik never needs to manage keys for every project you spin up on the VPS.
Prerequisites
You'll need a Linux VPS (Ubuntu 24.04 LTS works fine) with Docker and Docker Compose installed, OpenSSL and git on both the VPS and your local Mac, and a Next.js or Node.js application to point the proxy at.
Step 1: VPS Ingress Setup
On the VPS, create a central ingress directory at ~/proxy-server. This houses Traefik and Nginx for every project you run this pattern against.
yaml
# File: ~/proxy-server/docker-compose.ymlname:vps-dev-proxyservices:traefik:image:traefik:v3.7container_name:vps-dev-traefikrestart:unless-stoppedports:-'443:443'volumes:-/var/run/docker.sock:/var/run/docker.sock:ro-./traefik.yml:/etc/traefik/traefik.yml:ro-./dynamic:/etc/traefik/dynamic:roextra_hosts:-'host.docker.internal:host-gateway'networks:-default-proxydev-proxy:image:nginx:1.27-alpinecontainer_name:vps-dev-proxyrestart:unless-stoppedports:-'80:80'-'8443:443'volumes:-./nginx.d:/etc/nginx/conf.d:ro# Mount a cert directory per domain here-./certs/acme-app.dev:/etc/nginx/certs/acme-app.dev:roextra_hosts:-'host.docker.internal:host-gateway'networks:-default-proxylabels:-'traefik.enable=true'-'traefik.docker.network=vps-dev-proxy_default'-'traefik.tcp.routers.vps-dev-nginx.entrypoints=websecure'-'traefik.tcp.routers.vps-dev-nginx.rule=HostSNI(`*`)'-'traefik.tcp.routers.vps-dev-nginx.priority=1'-'traefik.tcp.routers.vps-dev-nginx.tls.passthrough=true'-'traefik.tcp.services.vps-dev-nginx.loadbalancer.server.port=443'networks:proxy:external:truename:proxy
This compose file binds port 443 on Traefik and routes every incoming SNI hostname to the Nginx container, which then terminates TLS using whichever cert directory matches the domain being requested. Adding a new project later means mounting another cert directory and an Nginx server block, nothing in the Traefik layer changes.
Step 2: Generate a Wildcard TLS Certificate
Because .dev domains are HSTS-preloaded, the browser needs a Certificate Authority it actually trusts. This script generates a Root CA once, then a wildcard server certificate signed by that CA with SANs covering both *.acme-app.dev and the bare acme-app.dev.
The regex in server_name catches every subdomain in one block, so demo.acme-app.dev and api.acme-app.dev both land here without adding a server block per subdomain. proxy_set_header Host $host is what preserves the real hostname on its way to your Next.js app, which is the whole reason this setup works for tenant routing.
Reload the proxy:
bash
cd ~/proxy-server && docker compose up -d --force-recreate
Step 4: Pick a Random Port to Avoid Collisions
Hardcoded ports like :3000 collide the moment a second project wants the same one on a shared VPS. Grab a free OS-assigned port instead:
Quit Chrome completely with Cmd + Q and reopen it. Chrome caches TLS socket pools in memory and won't pick up a newly trusted Keychain CA until it restarts.
Step 6: Point the VPS at Itself
Server-side fetches from Next.js Server Components or Payload CMS SSR also need to resolve the domain, this time from inside the VPS. Add the same hostnames to the VPS's own /etc/hosts, pointed at loopback:
subject=C = US, ST = Dev, L = Dev, O = Acme Corp Dev, CN = *.acme-app.dev
issuer=C = US, ST = US, L = Dev, O = Acme Corp Dev, CN = Acme Dev Root CA
Confirm the proxy reaches your app:
bash
curl -k -sS -I https://acme-app.dev/
Expected output:
text
HTTP/2 307
server: nginx/1.27.5
location: /en-US/
FAQ
Do I need to buy a real domain to use this?
No. .dev here is just a TLD you're using on a Root CA you generate and trust yourself. Nothing gets registered or resolved publicly, DNS resolution happens entirely through /etc/hosts on your Mac and the VPS.
Will this work for teammates on other machines?
Yes, as long as each teammate runs the install-mac-hosts.sh script and trusts the same Root CA certificate in their own Keychain. The certs and hosts files ship inside the dev-proxy/ folder in the repo for exactly this reason.
Can I run multiple projects behind the same VPS?
Yes. Each project gets its own cert directory, its own Nginx server block, and its own randomized port. Traefik's TCP passthrough routes by SNI hostname, so adding a project never touches the Traefik configuration itself.
Does this replace staging entirely?
No, and it isn't meant to. It closes the gap between localhost and staging so redirect logic, tenant routing, and cookie behavior get caught before a PR ever opens, which makes staging review faster because fewer environment-specific surprises show up there.
Why Traefik and Nginx together instead of just Nginx?
Traefik handles the shared port binding across every project on the VPS using TCP TLS passthrough. Nginx does the actual certificate termination per domain. Splitting the two means adding a new project is a matter of dropping in a new cert directory and server block, with zero changes to the shared Traefik layer.
Wrapping Up
A production-like local HTTPS setup turns localhost limitations into a solved problem: real subdomains with real Host headers, HSTS-compliant certificates trusted by your browser, and a stable port per project on a shared VPS. For multi-tenant apps, locale-aware redirects, or any auth flow depending on secure cookies, this is the difference between finding a bug in dev and finding it after a client already has.
Let me know in the comments if you have questions, and subscribe for more practical development guides.