BuildWithMatija
  1. Home
  2. Blog
  3. Tools
  4. Production-Like Local HTTPS: Real Domains, No Purchase

Production-Like Local HTTPS: Real Domains, No Purchase

VPS reverse proxy + wildcard TLS and a trusted Root CA to run real subdomains, HTTPS, and secure cookies locally

7th August 2026·Updated on:8th August 2026··
Tools
Production-Like Local HTTPS: Real Domains, No Purchase

📚 Get Practical Development Guides

Join developers getting comprehensive guides, code examples, optimization tips, and time-saving prompts to accelerate their development workflow.

No spam. Unsubscribe anytime.

📄View markdown version
0

Frequently Asked Questions

About the author

Matija Žiberna

Matija Žiberna

Full-stack developer, co-founder

AboutResume

Self-taught full-stack developer sharing lessons from building software and startups.

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

Contents

  • The Problem With Testing on `localhost`
  • Why This Matters Beyond Cosmetics
  • Multi-tenant subdomain routing
  • Edge middleware, rewrites, and redirect logic
  • Automated testing against real hostnames
  • Media, CDN, and mixed-content prevention
  • Secure cookies and auth flags
  • CORS and webhook callbacks
  • Architecture Overview
  • Prerequisites
  • Step 1: VPS Ingress Setup
  • Step 2: Generate a Wildcard TLS Certificate
  • Step 3: Nginx Server Block
  • Step 4: Pick a Random Port to Avoid Collisions
  • Step 5: Trust the Domain on Your Mac
  • Step 6: Point the VPS at Itself
  • Common Issues
  • Verifying the Setup
  • FAQ
  • Wrapping Up
On this page:
  • The Problem With Testing on `localhost`
  • Why This Matters Beyond Cosmetics
  • Architecture Overview
  • Prerequisites
  • Step 1: VPS Ingress Setup
Build with Matija logo

Build with Matija

Senior-led B2B websites, applications, content systems, and digital infrastructure. Business-first, full-stack, AI-assisted, no handoffs.

Services

  • B2B Website Development
  • CMS Architecture Review & Platform Blueprint
  • Next.js + Payload Advisory
  • AI Integration & Implementation

Resources

  • CMS Hub
  • B2B Website Strategy
  • E-commerce Hub
  • Blog
  • Case Studies

Payload CMS

  • Payload CMS Developer
  • Payload CMS Migration
  • Payload CMS Demos
  • All Payload CMS Resources

Discuss your project

Planning a rebuild, migration, application, workflow change, or platform decision? Start with the business problem and the system behind it.

Book a discovery callContact me →
© 2026Build with Matija•All rights reserved•Privacy Policy•Terms of Service
BuildWithMatija
Get In Touch

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.yml
name: vps-dev-proxy

services:
  traefik:
    image: traefik:v3.7
    container_name: vps-dev-traefik
    restart: unless-stopped
    ports:
      - '443:443'
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./traefik.yml:/etc/traefik/traefik.yml:ro
      - ./dynamic:/etc/traefik/dynamic:ro
    extra_hosts:
      - 'host.docker.internal:host-gateway'
    networks:
      - default
      - proxy

  dev-proxy:
    image: nginx:1.27-alpine
    container_name: vps-dev-proxy
    restart: unless-stopped
    ports:
      - '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:ro
    extra_hosts:
      - 'host.docker.internal:host-gateway'
    networks:
      - default
      - proxy
    labels:
      - '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: true
    name: 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.

bash
#!/usr/bin/env bash
# File: ~/proxy-server/scripts/generate-acme-app-dev-certs.sh
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CERT_DIR="$SCRIPT_DIR/../certs/acme-app.dev"
CA_KEY="$CERT_DIR/acme-app-dev-root-ca.key"
CA_CERT="$CERT_DIR/acme-app-dev-root-ca.pem"
SERVER_KEY="$CERT_DIR/_wildcard.acme-app.dev-key.pem"
SERVER_CSR="$CERT_DIR/_wildcard.acme-app.dev.csr"
SERVER_CERT="$CERT_DIR/_wildcard.acme-app.dev.pem"
SERVER_EXT="$CERT_DIR/_wildcard.acme-app.dev.ext"

mkdir -p "$CERT_DIR"

if [[ ! -f "$CA_KEY" || ! -f "$CA_CERT" ]]; then
  openssl genrsa -out "$CA_KEY" 4096

  openssl req \
    -x509 -new -nodes \
    -key "$CA_KEY" \
    -sha256 -days 3650 \
    -out "$CA_CERT" \
    -subj "/C=US/ST=Dev/L=Dev/O=Acme Corp Dev/CN=Acme Dev Root CA"
fi

openssl genrsa -out "$SERVER_KEY" 2048

openssl req \
  -new -key "$SERVER_KEY" \
  -out "$SERVER_CSR" \
  -subj "/C=US/ST=Dev/L=Dev/O=Acme Corp Dev/CN=*.acme-app.dev"

cat >"$SERVER_EXT" <<'EOF'
authorityKeyIdentifier=keyid,issuer
basicConstraints=CA:FALSE
keyUsage=digitalSignature,keyEncipherment
extendedKeyUsage=serverAuth
subjectAltName=@alt_names

[alt_names]
DNS.1=*.acme-app.dev
DNS.2=acme-app.dev
EOF

openssl x509 \
  -req -in "$SERVER_CSR" \
  -CA "$CA_CERT" -CAkey "$CA_KEY" \
  -CAcreateserial \
  -out "$SERVER_CERT" \
  -days 825 -sha256 \
  -extfile "$SERVER_EXT"

rm -f "$SERVER_CSR" "$SERVER_EXT"

echo "Certificates generated in $CERT_DIR"

Run it once per domain:

bash
chmod +x ~/proxy-server/scripts/generate-acme-app-dev-certs.sh
~/proxy-server/scripts/generate-acme-app-dev-certs.sh

The Root CA gets reused for every new subdomain you add under the same domain. Only your Mac needs to trust it, and only once.

Step 3: Nginx Server Block

Add a server block that redirects HTTP to HTTPS and terminates TLS for the domain and all its subdomains.

nginx
# File: ~/proxy-server/nginx.d/acme-app.dev.conf
server {
  listen 80;
  server_name acme-app.dev ~^(.+)\.acme-app\.dev$;

  return 301 https://$host$request_uri;
}

server {
  listen 443 ssl;
  http2 on;
  server_name acme-app.dev ~^(.+)\.acme-app\.dev$;

  ssl_certificate /etc/nginx/certs/acme-app.dev/_wildcard.acme-app.dev.pem;
  ssl_certificate_key /etc/nginx/certs/acme-app.dev/_wildcard.acme-app.dev-key.pem;

  client_max_body_size 100m;
  proxy_read_timeout 300s;
  proxy_send_timeout 300s;

  location / {
    proxy_pass http://host.docker.internal:24893;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Host $host;
    proxy_set_header X-Forwarded-Proto https;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection $connection_upgrade;
  }
}

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:

bash
python3 -c "import socket; s = socket.socket(); s.bind(('', 0)); print(s.getsockname()[1]); s.close()"
# Output example: 24893

Then point your dev script at it:

json
{
  "name": "my-web-app",
  "scripts": {
    "dev": "next dev --turbopack --port 24893"
  }
}

Use the same port number in your Nginx proxy_pass line from Step 3.

Step 5: Trust the Domain on Your Mac

Bundle the setup files into your project so any teammate can trust the same domain locally:

text
my-web-app/
└── dev-proxy/
    ├── README.md
    ├── certs/
    │   ├── acme-app-dev-root-ca.pem
    │   └── _wildcard.acme-app.dev.pem
    ├── hosts/
    │   ├── mac.txt   (YOUR_VPS_IP acme-app.dev demo.acme-app.dev)
    │   └── vps.txt   (127.0.0.1 acme-app.dev demo.acme-app.dev)
    └── install-mac-hosts.sh

Point your local machine at the VPS:

bash
#!/usr/bin/env bash
# File: dev-proxy/install-mac-hosts.sh
set -euo pipefail

cat << "EOF" | sudo tee -a /etc/hosts
# acme-app.dev dev proxy
YOUR_VPS_IP acme-app.dev demo.acme-app.dev api.acme-app.dev www.acme-app.dev
EOF
bash
sudo dev-proxy/install-mac-hosts.sh

Then trust the Root CA in the macOS System Keychain:

bash
sudo security add-trusted-cert -d -r trustRoot \
  -k /Library/Keychains/System.keychain \
  dev-proxy/certs/acme-app-dev-root-ca.pem

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:

text
127.0.0.1 acme-app.dev demo.acme-app.dev api.acme-app.dev www.acme-app.dev

Common Issues

SymptomCauseFix
openssl shows CN=TRAEFIK DEFAULT CERTThe cert volume was missing from docker-compose.yml and Nginx failed to start, so Traefik fell back to its default certAdd ./certs/<domain>:/etc/nginx/certs/<domain>:ro under dev-proxy and run docker compose up -d --force-recreate
NET::ERR_CERT_AUTHORITY_INVALIDmacOS Keychain doesn't trust the Root CA yet, or Chrome wasn't restartedRun the security add-trusted-cert command and restart Chrome with Cmd + Q
EADDRINUSE: address already in useThe chosen port is already taken by another processGenerate a fresh random port with the Python one-liner from Step 4
502 Bad GatewayNginx is up but the Next.js dev server isn't running on the port Nginx expectsStart pnpm dev --port <PORT> on the VPS host

Verifying the Setup

Confirm the TLS handshake presents your certificate, not a fallback:

bash
echo | openssl s_client -connect YOUR_VPS_IP:443 -servername acme-app.dev 2>/dev/null \
  | openssl x509 -noout -subject -issuer

Expected output:

text
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.

Thanks, Matija