---
title: "Production-Like Local HTTPS: Real Domains, No Purchase"
slug: "production-like-local-https-wildcard-tls"
published: "2026-08-07"
updated: "2026-08-08"
validated: "2026-08-08"
categories:
  - "Tools"
tags:
  - "local HTTPS"
  - "wildcard TLS"
  - "VPS reverse proxy"
  - "Root CA"
  - "subdomain routing"
  - "secure cookies"
  - "Traefik"
  - "Nginx"
  - "Next.js"
  - "Playwright testing"
  - "CORS"
  - "multi-tenant routing"
llm-intent: "reference"
audience-level: "intermediate"
framework-versions:
  - "traefik"
  - "nginx"
  - "docker"
  - "docker compose"
  - "openssl"
status: "stable"
llm-purpose: "Local HTTPS: set up a VPS reverse proxy with wildcard TLS and a trusted Root CA to test real subdomains, secure cookies, redirects, and CI tests. Follow…"
llm-prereqs:
  - "Access to Traefik"
  - "Access to Nginx"
  - "Access to Docker"
  - "Access to Docker Compose"
  - "Access to OpenSSL"
llm-outputs:
  - "Updated docker-compose configuration files for the new environment"
---

**Summary Triples**
- (vps-reverse-proxy, enables, real-subdomain-routing-for-local-dev)
- (root-ca, is-generated-locally, and-imported-into-macos-keychain-to-trust-certificates)
- (wildcard-certificate, is-signed-by, the-local-root-ca-and-installed-on-the-vps-proxy)
- (ssh-reverse-tunnel, forwards, traffic-from-vps-to-local-dev-port)
- (traefik-or-nginx, routes, incoming-requests-for-*.your-domain-to-local-tunnel-ports)
- (browser-tests, use, trusted-wildcard-certs-to-exercise-HSTS-subdomain-redirects-and-secure-cookies)
- (Playwright/Cypress, are-configured-to, target-real-https-origins-(no-ignore-https-required-when-CA-is-trusted))
- (no-domain-purchase, is-achieved-by, using-a-vps-hosted-wildcard-domain-and-a-trusted-local-root-ca)
- (HSTS-preload, is-handled-by, serving-a-valid-HTTPS-certificate-signed-by-the-trusted-root-ca)

### {GOAL}
Local HTTPS: set up a VPS reverse proxy with wildcard TLS and a trusted Root CA to test real subdomains, secure cookies, redirects, and CI tests. Follow…

### {PREREQS}
- Access to Traefik
- Access to Nginx
- Access to Docker
- Access to Docker Compose
- Access to OpenSSL

### {STEPS}
1. Prepare VPS and prerequisites
2. Create VPS ingress with Traefik
3. Generate a Root CA and wildcard cert
4. Add Nginx server block for TLS
5. Avoid port collisions with random port
6. Trust the Root CA on macOS
7. Update VPS hosts for server-side fetches
8. Verify TLS and proxy behavior

<!-- llm:goal="Local HTTPS: set up a VPS reverse proxy with wildcard TLS and a trusted Root CA to test real subdomains, secure cookies, redirects, and CI tests. Follow…" -->
<!-- llm:prereq="Access to Traefik" -->
<!-- llm:prereq="Access to Nginx" -->
<!-- llm:prereq="Access to Docker" -->
<!-- llm:prereq="Access to Docker Compose" -->
<!-- llm:prereq="Access to OpenSSL" -->
<!-- llm:output="Updated docker-compose configuration files for the new environment" -->

# Production-Like Local HTTPS: Real Domains, No Purchase
> Local HTTPS: set up a VPS reverse proxy with wildcard TLS and a trusted Root CA to test real subdomains, secure cookies, redirects, and CI tests. Follow…
Matija Žiberna · 2026-08-07

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

| Symptom | Cause | Fix |
|---|---|---|
| `openssl` shows `CN=TRAEFIK DEFAULT CERT` | The cert volume was missing from `docker-compose.yml` and Nginx failed to start, so Traefik fell back to its default cert | Add `./certs/<domain>:/etc/nginx/certs/<domain>:ro` under `dev-proxy` and run `docker compose up -d --force-recreate` |
| `NET::ERR_CERT_AUTHORITY_INVALID` | macOS Keychain doesn't trust the Root CA yet, or Chrome wasn't restarted | Run the `security add-trusted-cert` command and restart Chrome with `Cmd + Q` |
| `EADDRINUSE: address already in use` | The chosen port is already taken by another process | Generate a fresh random port with the Python one-liner from Step 4 |
| `502 Bad Gateway` | Nginx is up but the Next.js dev server isn't running on the port Nginx expects | Start `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

## LLM Response Snippet
```json
{
  "goal": "Local HTTPS: set up a VPS reverse proxy with wildcard TLS and a trusted Root CA to test real subdomains, secure cookies, redirects, and CI tests. Follow…",
  "responses": [
    {
      "question": "What does the article \"Production-Like Local HTTPS: Real Domains, No Purchase\" cover?",
      "answer": "Local HTTPS: set up a VPS reverse proxy with wildcard TLS and a trusted Root CA to test real subdomains, secure cookies, redirects, and CI tests. Follow…"
    }
  ]
}
```