Lancert: Let's Encrypt Certificates for Local Development on Private IPs
I was working on BeeBuzz, a privacy-first push notification service I’m building in my spare time. BeeBuzz is a Progressive Web App — web-only by design, as I explained in the first devlog post. That choice keeps the surface area small and avoids the complexity of native apps. But it also means I depend on the browser for everything — including push notifications.
Push notifications on the web require a Service Worker. Service Workers require HTTPS. On localhost, browsers make an exception: http://localhost just works. But “localhost” only means the machine you’re sitting at. The moment you open the same URL from your phone on the local network, you’re hitting an IP address — https://192.168.1.50:8443 — and the exception doesn’t apply. The browser wants a valid TLS certificate.
This is a well-known problem, and there are well-known workarounds. BeeBuzz already uses Caddy as its web server and reverse proxy, so I started there. Caddy has a built-in internal CA — Caddy Local Authority — that automatically generates trusted certificates for local and internal addresses. With tls internal in the Caddyfile, HTTPS on the development machine worked flawlessly: no configuration, no manual steps, no external dependencies.
The problem appeared when I needed to test from a phone. Caddy’s internal CA is only trusted on the machine where Caddy installed its root certificate. To trust it on a mobile device, you need to manually install the root CA — on iOS that means downloading the profile, navigating to Settings, enabling full trust for the certificate. On Android, the steps vary by version. It’s doable, but it’s per-device friction that repeats if the root certificate changes.
Caddy does have another path: DNS provider plugins. With a plugin like caddy-dns/cloudflare or caddy-dns/hetzner, Caddy can obtain real Let’s Encrypt certificates using DNS-01 challenges, even for internal hosts. But that requires recompiling Caddy with xcaddy to include the plugin, creating an API token on your DNS provider, and configuring it in the Caddyfile. It works, but it’s per-project configuration overhead tied to a specific DNS provider — not what I wanted for a local development workflow.
I needed something simpler: a real, browser-trusted TLS certificate that works on any device, without installing a custom CA on each client.
The idea
The idea of encoding IP addresses in DNS names isn’t new. Services like sslip.io (and before it, xip.io and nip.io) have been doing this for years — resolve 192-168-1-50.sslip.io and you get 192.168.1.50 back. They’re excellent for development and testing, and sslip.io is the direct inspiration for the DNS component of lancert.
What those services don’t do is serve certificates. To get a TLS certificate for a sslip.io hostname, you need to run the HTTP-01 challenge yourself. That means exposing your development machine to the internet — via port forwarding, a tunnel, or a VPN — running an ACME client, and managing renewal. sslip.io also doesn’t support wildcard certificates, so each hostname requires its own challenge. It all works, but it’s a lot of moving parts for what should be a simple local development task.
Tools like mkcert solve the certificate side by generating locally-trusted certs without any of that, but they require installing a custom root CA on every device you test from — the same friction I ran into with Caddy’s internal CA.
lancert combines DNS resolution and certificate issuance into a single service, using real Let’s Encrypt certificates. No custom CA, no per-device setup. For most common LAN IPs, the certificate is already pre-issued — one GET and you’re done. For other IPs, a POST triggers issuance and a follow-up GET retrieves it.
The underlying mechanism is straightforward. Let’s Encrypt issues real certificates. It validates domain ownership via DNS challenges. If you control the DNS for a domain, you can prove ownership of any subdomain — including one that resolves to a private IP address.
While unusual, public DNS records can point to private IP addresses like 192.168.1.50. The DNS system itself does not enforce routability.
So: run an authoritative DNS server for a domain, make subdomains resolve to private IPs by encoding the address in the name, and use DNS-01 challenges to get wildcard certificates from Let’s Encrypt. The result is a real, trusted certificate for a name like 192-168-1-50.lancert.dev — which resolves to 192.168.1.50.
No custom CA. No manual installation. No device-specific setup. Every browser, on every device, trusts it out of the box.
Scope and trade-offs
lancert is designed for one scenario: you’re developing on a private network and you need a browser-trusted HTTPS certificate to test from other devices on the same LAN. Service Workers, push notifications, WebRTC, secure contexts — anything that requires HTTPS on a real device.
If that’s your situation, lancert gives you a real Let’s Encrypt certificate with one curl and no certificate installation on client devices.
If that’s not your situation, there are better tools. If you only test on the machine running the server, browsers treat http://localhost as a secure context even without HTTPS — you don’t need lancert. If you need actual TLS security on your LAN and you control all the devices, mkcert with a locally-installed root CA is a better choice — and if you already use Caddy, its built-in internal CA with tls internal does the same thing without an extra tool. If you already manage DNS and want full control over certificate issuance, Caddy with a DNS provider plugin and xcaddy gives you that.
It’s important to understand what lancert does not provide: confidentiality. The private keys are served via API to anyone who requests them. There is no ownership concept for private IPs — 192.168.1.50 on your network is the same address as 192.168.1.50 on someone else’s. Anyone who knows the IP can download the same certificate and private key. The browser will show a valid HTTPS connection, but this does not mean the traffic is protected from other devices on the same network.
The threat model is simple: you trust your local network enough to develop on it, and you need the browser to trust your certificate. That’s it.
How lancert works
lancert is an authoritative DNS server written in Go, deployed on a public host. It serves the lancert.dev zone and handles certificate issuance.
The flow has four parts:
DNS resolution. When a client queries 192-168-1-50.lancert.dev, lancert parses the IP from the subdomain name and responds with an A record pointing to 192.168.1.50. This happens for any RFC 1918 address encoded in the subdomain. There’s no database of records — the IP is the name.
Certificate issuance. When you request a certificate for an IP, lancert starts an ACME DNS-01 challenge with Let’s Encrypt. Since lancert is both the authoritative nameserver for lancert.dev and the ACME client, there is no propagation delay — the TXT record is available the moment Let’s Encrypt queries for it. The challenge typically completes in a few seconds.
Wildcard coverage. Each certificate includes both the base domain (192-168-1-50.lancert.dev) and a wildcard SAN (*.192-168-1-50.lancert.dev). This means you can use any subdomain — app.192-168-1-50.lancert.dev, api.192-168-1-50.lancert.dev — without requesting additional certificates.
Caching and renewal. Once issued, certificates are cached. Subsequent requests return the cached cert instantly. Renewal happens automatically before expiry on the next request.
Using it
Most common LAN IPs are pre-issued — just grab the certificate:
curl https://lancert.dev/certs/192.168.1.50The response includes the full chain, private key, covered domains, and expiry. DNS already resolves the subdomain to your private IP:
dig 192-168-1-50.lancert.dev +short
192.168.1.50If your IP isn’t pre-issued, a POST triggers on-demand issuance:
curl -X POST https://lancert.dev/certs/192.168.1.77The POST returns 202 if issuance started, 200 if the cert was already cached. Poll with GET until it’s ready — usually a few seconds.
To use the certificate with Caddy, download the PEM files and reference them in the Caddyfile:
curl -o fullchain.pem https://lancert.dev/certs/192.168.1.50/fullchain.pem
curl -o privkey.pem https://lancert.dev/certs/192.168.1.50/privkey.pem192-168-1-50.lancert.dev, *.192-168-1-50.lancert.dev {
tls fullchain.pem privkey.pem
reverse_proxy localhost:8080
}Any device on the LAN can now open https://192-168-1-50.lancert.dev and get a valid HTTPS connection — including a phone testing push notifications.
For the full API reference, see the documentation on lancert.dev.
Pre-issued certificates
Let’s Encrypt has rate limits. To respect those limits, lancert pre-issues certificates for the most common private IPs: gateway defaults like 192.168.0.1, 192.168.1.1, and 10.0.0.1, Docker’s default bridge at 172.17.0.1, and typical static host addresses across the 192.168.0.x, 192.168.1.x, and 10.0.x.x ranges. The full list is in the repository.
For most local development setups, the certificate you need already exists. The GET request returns immediately.
If your IP isn’t in the pre-issued list, the POST endpoint creates one on demand. Once created, it’s cached and renewed like any other.
What’s next
lancert solves my immediate problem and I’m releasing it because it might solve yours too. The project is open source at github.com/lucor/lancert.
A note on availability: lancert.dev is a single service running on a single host. If it goes down, certificates you’ve already downloaded keep working until they expire, but you won’t be able to request new ones until it’s back. For a local development tool, I consider this an acceptable trade-off. If you need stronger guarantees, the source is there — you can run your own instance.
Like my other projects, lancert is intentionally simple. It does one thing — real TLS certificates for private IPs — and I plan to keep it that way. That philosophy is the same one behind BeeBuzz: small scope, clear constraints, no features added just because they’re possible.
If you’re curious about BeeBuzz, the project that started all this, you can read the devlog and the architecture post. You can also sign up for early beta access at beebuzz.app.