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

·14 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.

Friday night, yet another EADDRINUSE

$ bun dev
error: bind EADDRINUSE 0.0.0.0:3000

Again. Third one today. I kill another project to free the port, I restart, it boots, and then Google OAuth throws me an error I've never seen before:

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

I'd added https://myapp.localhost/api/auth/callback/google as a redirect URI. Out of optimism. Google doesn't accept it anymore. Not in 2026.

22 projects in ~/projects/. 7 active ports in parallel. Secure cookies that won't set over HTTP. And the joy of typing in different Google credentials for every clone. An hour later, I figure there has to be something better.

There is. Way better. And cleaner than anything I'd hacked together before.

The not-so-great ideas (I ruled out)

❌ Manual 30xxx port plan

Mnemonic at first. Tech debt after six months. lsof -i :30130 reminds me what the number maps to, but I forget every time I come back to a project after two weeks. And it doesn't fix HTTPS, cookies, or mobile testing.

*.localhost behind a reverse proxy

On paper: https://myapp.localhost. Nice. In practice: Google OAuth refuses .localhost. It's not a real TLD. Blocking for anything touching Sign in with Google (and probably Microsoft, Stripe, GitHub soon).

❌ Public tunnels like ngrok

Shared URLs. Risk of OAuth redirect hijacking documented by Microsoft in March 2026 on recycled temporary subdomains: an attacker who inherits your old ngrok subdomain grabs your OAuth codes as long as you haven't removed the redirect URI from the provider. The ngrok free tier rotates subdomains. So avoid for OAuth.

⚠️ Caddy + lvh.me (or localtest.me)

Works great. lvh.me is a public domain whose subdomains all resolve to 127.0.0.1 via wildcard DNS. Accepted by Google since .me is a real TLD. 5-minute setup.

But the TLS cert stays self-signed (Caddy Local CA):

  • Root cert to install on every machine and every mobile device
  • Painful to share with a colleague (they have to trust your CA)
  • Dependency on a domain a friendly stranger has been maintaining for ten years (thanks to that person, by the way)

So it's the right "5 minutes" tradeoff. It's not the right "I have time and a domain" choice.

The stack I landed on

[Chrome]
     ├─ DNS query : myproject.dev.example.com
     │   → Cloudflare DNS responds: 127.0.0.1
     ├─ HTTPS handshake
     │   → Let's Encrypt wildcard cert *.dev.example.com
     │   → trusted natively (ISRG Root X1 chain), zero warning
     └─ HTTP traffic (loopback)
         → local Caddy :443
         → reverse_proxy localhost:<port>
Next.js / TanStack Start

The components:

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

The key piece is the DNS-01 challenge. Let's Encrypt can't reach your 127.0.0.1 from the Internet to do the classic HTTP-01 challenge. So Caddy proves it controls the domain by temporarily posting a TXT record _acme-challenge.dev.example.com via the Cloudflare API. Once Let's Encrypt is happy, the record is removed. Caddy renews itself every 60 days. You touch nothing.

Why not just stay with your registrar

My domain was at Squarespace (heritage of the Google Domains acquisition in 2023). First reflex: add the wildcard A record *.dev.example.com → 127.0.0.1 at Squarespace, keep their nameservers, run Caddy with a DNS-01 challenge against them.

Blocked cold. Squarespace exposes no public DNS API in 2026. The community repo caddy-dns hosts 96 providers (Cloudflare, Route53, OVH, Hetzner, Gandi...). Squarespace absent. No Certbot Squarespace plugin either. Without an automatable DNS API, no DNS-01 challenge, no auto-renewed wildcard cert.

The nuance that made things click: a domain has a registrar (who sells it to you, manages ICANN ownership, renews it yearly) and a DNS host (who answers DNS queries). Two independent services. You can keep Squarespace as registrar (continuity, no transfer, same invoice) and delegate DNS resolution only to Cloudflare via the NS records. Standard practice since ~2020.

Extra cost: €0. Cloudflare DNS is free on the Free plan, unlimited records.

Step-by-step setup

1. DNS snapshot before migration

First, a full audit of your current DNS zone. You'll need it to recreate it at the new DNS host and for a potential rollback (trust me, you want this safety net).

# Inventory of visible records
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 auto-scan when you add your domain only finds the common subdomains (www, mail, api, etc.). All your custom subdomains (prod envs, staging...) must be added by hand. Open your registrar's admin UI and list everything exhaustively before you switch. I skipped a staging.example.com the first time and spent three days figuring out why a dev kept complaining their env was returning 404s.

2. Cloudflare: add the domain

dash.cloudflare.comAdd a Site → enter the domain → Free plan.

Cloudflare scans, offers you detected records. Compare with your snapshot. Add what's missing. For anything pointing to Vercel: switch to DNS only (grey cloud). Double-proxy Cloudflare → Vercel breaks preview deployments and Vercel's image optimization. I tested it. It's ugly.

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 (resolves server-side), so even @ CNAME cname.vercel-dns.com works. Much more future-proof than a legacy Vercel fixed IP.

3. Nameserver switch at the registrar

Cloudflare gives you two NS (like xxx.ns.cloudflare.com and yyy.ns.cloudflare.com). At your registrar, in the custom nameservers section, replace the existing NS with the two Cloudflare ones.

⚠️ Copy-paste, never type by hand. One letter off and the migration never lands. I burned a solid hour on a josh instead of jose. Hurts.

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

Propagation: 5 minutes to 24h depending on TLD. For .io, expect 30 min to 2h in practice. Check:

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

4. Wildcard *.dev → loopback

Once propagation is done, add the wildcard A record. Either via Cloudflare UI, or one command with your API token (scope Zone:DNS:Edit on this zone 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 = "Auto" in the API. proxied: false = DNS only (no Cloudflare proxy).

5. Caddy with Cloudflare plugin

Stock Caddy doesn't know how to do a Cloudflare DNS-01 challenge. You have to 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 should show dns.providers.cloudflare. If nothing, the build failed silently (sometimes xcaddy doesn't tell you when a plugin has a version conflict). Rerun with -v.

6. Cloudflare API token

dash.cloudflare.com/profile/api-tokens → template Edit zone DNS → scope to the zone only. Copy the token (visible once), inject it into systemd Caddy:

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

⚠️ reload does not propagate Environment=, only restart does. A trap that cost me a few minutes of pulling my hair out watching Caddy logs scream "Cloudflare API: 401 unauthorized" while the token worked when I tested it with 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
    }
}

⚠️ The handle @xxx { import dev_app NNN } format on a single line crashes Caddy. Always three lines minimum. Took me twenty minutes to find that the first time. It's documented nowhere, it's just how it is.

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

If you put your Caddyfile in your home (typical: ~/projects/.dev-proxy/Caddyfile), the system caddy user (started by systemd) won't be able to read it: /home/<user> is 750. Surgical solution 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

Much cleaner than chmod 755 /home/<user> which would expose your home to everyone.

9. Start + test

sudo systemctl restart caddy
sudo journalctl -u caddy -f

On first access to https://myapp1.dev.example.com, Caddy asks Let's Encrypt for 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"

13 seconds for me. The cert now covers all *.dev.example.com subdomains, present and future.

10. Project side

For each project, three minimal changes.

package.json: pin the port (deterministic, hash-generated):

"dev": "next dev -p 31234"

.env: switch URLs to the dev domain:

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

next.config.ts: allow the origin in dev (otherwise HMR WebSocket gets 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, on each OAuth client:

  • Authorized JavaScript origins: https://myapp1.dev.example.com
  • Authorized redirect URIs: https://myapp1.dev.example.com/api/auth/callback/google

Keep the old http://localhost:XXXX/... URLs in parallel during transition. You'll be glad you can roll back if something breaks.

The automation layer: a Claude Code skill

Eleven steps to add a project. Too many for a procedure I'll repeat for every new repo. I encoded all of this into a Claude Code skill (add-dev-subdomain) that orchestrates:

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

Hybrid pattern: AI orchestrator agent + Python helper script for deterministic operations (port computation, robust JSON parsing). It's what 2026 sources describe as best practice for this kind of workflow (DEV Community, 2026).

A typical session:

You: "add the myapp3 project to dev"
Claude: [audit → port 38291 generated → 6 files modified → 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 risks of forgetting things, like "ah I forgot allowedDevOrigins, that's why HMR is laggy") to thirty seconds with automatic validation.

What I figured out along the way

2026 DNS migration best practice

The consensus from 2026 guides (No-IP, ZoneWatcher):

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

The "keep ≥ 1 week" isn't "keep forever". It's a bounded safety net. Once migration is stable, delete the archived records at the old DNS host to avoid drift and future confusion ("wait, my records are up to date at which provider again?").

Never paste an API token into an AI chat

Obvious obviousness, but easy trap under pressure. When your token appears in a conversation, it's potentially logged by intermediate systems. Post-incident procedure: revoke immediately, create a new one, edit it straight into the systemd file with sudo systemctl edit caddy (no copy-paste into chat).

The second time, you know. The first time, it stings.

Cloudflare clean vs Cloudflare purist

I left my records configured in the Cloudflare web UI. The 2026 enterprise best practice would be IaC: Terraform Cloudflare provider (or OctoDNS, which Cloudflare uses internally for its own DNS - details), with:

  • Cloudflare Dashboard in read-only mode (toggle in Settings)
  • Single source of truth in Git
  • terraform plan on PR, terraform apply on merge

Overkill for a solo setup with one domain and rare changes. But relevant the moment there's a team and multiple zones.

lvh.me is still the right choice for the 5-minute scenario

If you don't have a domain, or you just want to test quickly, lvh.me (or its cousin localtest.me) does exactly the job in five minutes:

  • Public wildcard DNS, all subdomains resolve to 127.0.0.1
  • TLD .me accepted by Google OAuth
  • No DNS to configure

Only downsides: Caddy Local CA cert to trust manually, dependency on a free third-party service that might disappear one day.

The result, concretely

  • 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 public TLD and valid cert
  • Secure / SameSite=None cookies behave like in prod
  • Mobile testing directly from the iPhone on the LAN, without installing a CA on iOS (which is a security nightmare)
  • Automatic cert renewal every 60 days, I forget it exists
  • One command to add a new project: "add project X to dev"

All for €0 more than my previous setup. Cloudflare free plan is plenty, Let's Encrypt is free, Caddy is open source.

Three months after the migration, I haven't touched a thing. No cert has expired without warning. No project has crashed because of the proxy. No OAuth waking up one morning to tell me my localhost:3847 is invalid. I think that's what luxury is.

Sources

ShareLinkedInXBluesky

Related articles