Local HTTPS in 2026: one Let's Encrypt cert for all my dev projects

Local HTTPS in 2026: one Let's Encrypt cert for all my dev projects

·11 min read·Updated on May 24, 2026
Who this is for

You have multiple dev projects running locally (3, 10, 30+), you own a domain, and you're tired of juggling ports or self-signed certs. Article validated on WSL2 Ubuntu 24.04 but applies to native Linux and macOS.

The problem

$ bun dev
error: bind EADDRINUSE 0.0.0.0:3000

Twenty-two projects in ~/projects/, seven ports running in parallel. And since 2026, Google OAuth refuses .localhost redirect URIs:

Invalid origin: the URI must end with a public top-level domain extension, such as .com or .org.

Add Secure cookies that won't set over HTTP, and a different set of Google credentials to dictate on every clone.

The false good ideas

❌ A hand-rolled 30xxx port plan. Mnemonic at first, technical debt after six months. lsof -i :30130 reminds you what the number maps to, but you forget it the moment you return to a project after two weeks. And it fixes neither HTTPS, nor cookies, nor mobile testing.

*.localhost behind a reverse proxy. Nice on paper, but Google OAuth refuses .localhost: it isn't a real TLD. Blocking for anything involving Sign in with Google (and probably Microsoft, Stripe, GitHub soon enough).

❌ Public tunnels like ngrok. Shared URLs, plus the OAuth redirect hijacking risk Microsoft documented in March 2026 on recycled temporary subdomains: an attacker inheriting your old ngrok subdomain harvests your OAuth codes until you remove the redirect URI at the provider. The ngrok free tier rotates subdomains.

⚠️ Caddy + lvh.me (or localtest.me). It works well: lvh.me is a public domain whose subdomains all resolve to 127.0.0.1 via wildcard DNS, and .me is a real TLD Google accepts. Five-minute setup. But the TLS cert stays self-signed (Caddy Local CA): a root cert to install on every machine and every phone, awkward to share with a colleague, and a dependency on a third-party domain. It's the right "5 minutes" trade-off, not the right "I have time and a domain" choice.

The stack I kept

The components:

  • Wildcard DNS *.dev.example.com → 127.0.0.1 on your domain, hosted by Cloudflare (free plan)
  • Caddy on the dev machine, rebuilt with the Cloudflare DNS plugin for the ACME DNS-01 challenge
  • A single Let's Encrypt wildcard cert *.dev.example.com, renewed every ~60 days, trusted everywhere by default because it's signed by a public CA
  • Backend ports generated by deterministic hash sha256(project_name) % 9900 + 30100, stable and opaque — you never see them, Caddy handles routing

The important piece is the DNS-01 challenge. Let's Encrypt can't reach your 127.0.0.1 from the Internet for a classic HTTP-01 challenge. So Caddy proves domain control by temporarily setting a _acme-challenge.dev.example.com TXT record via the Cloudflare API, then removing it. Automatic renewal every 60 days.

Why not stay with your registrar

First instinct with a domain at Squarespace: add the wildcard A record there and run Caddy with a DNS-01 challenge against them. Dead end — Squarespace exposes no public DNS API in 2026. The community caddy-dns repo hosts 96 providers (Cloudflare, Route53, OVH, Hetzner, Gandi…), Squarespace absent, and there's no Certbot plugin either. Without an automatable DNS API, no auto-renewed wildcard cert.

The distinction that unlocks it: a domain has a registrar (who sells it to you and manages ICANN ownership) and a DNS host (who answers DNS queries). Two independent services. You can keep your current registrar and delegate only DNS resolution to Cloudflare via NS records. Standard practice since ~2020, and €0 extra (Cloudflare DNS is free on the Free plan, unlimited records).

Step-by-step setup

1. DNS snapshot before migrating

A full zone audit, needed to recreate it at the new DNS host and for a possible rollback:

dig +short example.com NS
dig +short example.com A
dig +short example.com MX
dig +short example.com TXT
dig +short _dmarc.example.com TXT
dig +short google._domainkey.example.com TXT
# + every known subdomain

⚠️ Classic trap: Cloudflare's automatic scan only finds common subdomains (www, mail, api). Every custom subdomain (prod envs, staging) must be added by hand. List them exhaustively from your registrar's admin before switching — a forgotten staging.example.com turns into a 404 that takes days to trace.

2. Cloudflare: add the domain

dash.cloudflare.comAdd a Site → domain → Free plan. Compare the detected records with your snapshot and add the missing ones.

For anything pointing at Vercel: switch to DNS only (grey cloud). The Cloudflare → Vercel double proxy breaks preview deployments and Vercel image optimization.

2026 bonus: for A records pointing at Vercel, replace them with CNAMEs to cname.vercel-dns.com. On the apex, Cloudflare does native CNAME flattening, so even @ CNAME cname.vercel-dns.com works — far more durable than a legacy fixed Vercel IP.

3. Switch nameservers at the registrar

Cloudflare gives you two NS (xxx.ns.cloudflare.com, yyy.ns.cloudflare.com) to set as custom nameservers at the registrar.

⚠️ Copy-paste, never type by hand. One letter off and the migration never completes.

⚠️ Before switching: if DNSSEC is enabled at the registrar, disable it. You can re-enable it afterwards from Cloudflare.

Propagation: 5 minutes to 24h depending on the TLD (30 min to 2h in practice for .io). Verify:

dig +short @1.1.1.1 example.com NS  # must return the Cloudflare NS
dig +short @8.8.8.8 example.com NS  # same

4. Wildcard *.dev → loopback

Via the Cloudflare UI, or in one command with an API token (scope Zone:DNS:Edit on this domain only):

curl -X POST "https://api.cloudflare.com/client/v4/zones/<ZONE_ID>/dns_records" \
  -H "Authorization: Bearer <CLOUDFLARE_API_TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{"type":"A","name":"*.dev","content":"127.0.0.1","ttl":1,"proxied":false}'

ttl: 1 means "Auto" in the API. proxied: false means DNS only.

5. Caddy with the Cloudflare plugin

Stock Caddy can't do a Cloudflare DNS-01 challenge; rebuild it with xcaddy:

# Install Go then xcaddy
sudo apt install -y golang-go
go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest

# Build with the Cloudflare DNS plugin
~/go/bin/xcaddy build --with github.com/caddy-dns/cloudflare

# Replace the system binary
sudo systemctl stop caddy
sudo cp ./caddy /usr/bin/caddy
sudo systemctl start caddy

Check: caddy list-modules | grep cloudflare must print dns.providers.cloudflare. If nothing comes out, the build failed silently (xcaddy doesn't always report a version conflict) — re-run with -v.

6. Cloudflare API token

dash.cloudflare.com/profile/api-tokensEdit zone DNS template → scoped to that zone only. Copy the token (shown once) and inject it into systemd:

sudo systemctl edit caddy
[Service]
Environment="CLOUDFLARE_API_TOKEN=<your-token>"
sudo systemctl daemon-reload
sudo systemctl restart caddy   # restart, not reload

⚠️ reload doesn't propagate Environment=, only restart does. Misleading symptom: Caddy shouting "Cloudflare API: 401 unauthorized" while the token works perfectly under curl.

7. Caddyfile

{
    email you@example.com
    auto_https disable_redirects
}

(dev_app) {
    encode gzip zstd
    reverse_proxy localhost:{args[0]}
}

*.dev.example.com {
    tls {
        dns cloudflare {env.CLOUDFLARE_API_TOKEN}
    }

    @myapp1  host myapp1.dev.example.com
    @myapp2  host myapp2.dev.example.com

    handle @myapp1 {
        import dev_app 31234
    }
    handle @myapp2 {
        import dev_app 35678
    }

    handle {
        respond "Unknown dev subdomain" 404
    }
}

⚠️ Writing handle @xxx { import dev_app NNN } on a single line crashes Caddy. Always three lines minimum. It's documented nowhere.

8. POSIX ACLs (WSL2 / multi-user Linux)

If the Caddyfile lives in your home (typically ~/projects/.dev-proxy/Caddyfile), the caddy system user started by systemd can't read it, since /home/<user> is 750. A surgical fix, without loosening general permissions:

sudo setfacl -m u:caddy:x  /home/<user>
sudo setfacl -m u:caddy:x  /home/<user>/projects
sudo setfacl -m u:caddy:rx /home/<user>/projects/.dev-proxy
sudo setfacl -m u:caddy:r  /home/<user>/projects/.dev-proxy/Caddyfile
sudo setfacl -d -m u:caddy:r /home/<user>/projects/.dev-proxy  # default ACL for future files

Far cleaner than chmod 755 /home/<user>, which would expose the whole home directory.

9. Run and test

sudo systemctl restart caddy
sudo journalctl -u caddy -f

On the first hit to https://myapp1.dev.example.com, Caddy requests the wildcard cert:

"trying to solve challenge","identifier":"*.dev.example.com","challenge_type":"dns-01"
"certificate obtained successfully","issuer":"acme-v02.api.letsencrypt.org-directory"

About fifteen seconds. The cert now covers every *.dev.example.com subdomain, present and future.

10. Project side

Three minimal changes per project. package.json, to pin the deterministic port:

"dev": "next dev -p 31234"

.env, to move the URLs onto the dev domain:

BETTER_AUTH_URL=https://myapp1.dev.example.com
NEXT_PUBLIC_APP_URL=https://myapp1.dev.example.com

next.config.ts, to allow the origin in dev (otherwise the HMR WebSocket is refused):

const nextConfig: NextConfig = {
  allowedDevOrigins: ['myapp1.dev.example.com'],
}

For Vite/TanStack Start: server.allowedHosts: ['myapp1.dev.example.com'] in vite.config.ts.

11. Google OAuth (if applicable)

In Google Cloud Console, for each OAuth client: Authorized JavaScript originshttps://myapp1.dev.example.com, Authorized redirect URIshttps://myapp1.dev.example.com/api/auth/callback/google. Keep the old http://localhost:XXXX/... URLs in parallel during the transition so you can roll back.

Automation: a Claude Code skill

Eleven steps per project is too many for a procedure repeated on every new repo. It's all encoded in a Claude Code skill (add-dev-subdomain) that orchestrates:

  • Automatic project audit: framework (Next.js / TanStack / Vite / Turbo monorepo), env files (resolving .vscode/.env.local symlinks), presence of Google OAuth
  • Deterministic port: sha256(name) % 9900 + 30100 with collision resolution against a version-controlled ports.json
  • Atomic edits: package.json, .env*, framework config, Caddyfile, ports.json
  • Caddy reload + validation test (dig + curl)
  • Final recap with the exact URL to add in Google Console if OAuth is detected

A hybrid pattern: AI orchestrator agent + Python helper script for the deterministic operations (port computation, robust JSON parsing).

You:    "add project myapp3 to dev"
Claude: [audit → port 38291 generated → 6 files edited → Caddy reload → tests OK]
Remaining manual action: add
          https://myapp3.dev.example.com/api/auth/callback/google
          in Google Cloud Console
To start: cd ~/projects/myapp3 && bun dev

Adding a project goes from ten minutes, with the classic omissions like a missing allowedDevOrigins, to thirty seconds with automatic validation.

What to take away

The 2026 DNS migration best practice (No-IP, ZoneWatcher):

inventory → lower TTLs 48-72h ahead → add records at the new provider
→ verify with dig → switch nameservers → keep the old zone live ≥ 1 week
THEN clean up

"Keep ≥ 1 week" is a bounded safety net, not forever. Once the migration is stable, deleting the archived records at the old DNS host prevents drift and future confusion.

Never paste an API token into an AI chat. Obvious, and easy to forget under pressure: a token that appears in a conversation is potentially logged by intermediate systems. Post-incident procedure: revoke immediately, create a new one, edit it directly into the systemd file with sudo systemctl edit caddy.

Cloudflare via UI vs IaC. The 2026 enterprise best practice would be the Terraform Cloudflare provider (or OctoDNS, which Cloudflare uses internally — details), with a read-only dashboard, a single source of truth in Git, and terraform plan on PRs. Overkill for a solo setup with one domain, relevant as soon as there's a team and several zones.

lvh.me stays the right choice for the 5-minute scenario, if you don't own a domain: public wildcard DNS to 127.0.0.1, .me TLD accepted by Google OAuth, nothing to configure. At the cost of a Local CA cert to trust manually and a dependency on a free third-party service.

The result

  • No more EADDRINUSE: each project has a stable, opaque, deterministically generated port
  • Native green padlock in every browser, no root cert installation anywhere
  • OAuth providers (Google, GitHub, Stripe) accept *.dev.example.com URLs because the TLD is public and the cert valid
  • Secure / SameSite=None cookies behave exactly as in production
  • Mobile testing straight from the iPhone on the LAN, without installing a CA on iOS
  • Automatic cert renewal every 60 days
  • One command to add a new project

All for €0 more: Cloudflare Free plan, free Let's Encrypt, open-source Caddy. Three months after the migration: no expired cert, no project broken by the proxy, no invalidated OAuth.

Sources

ShareLinkedInXBluesky

Related articles