HTTPS on localhost with Caddy and memorable .localhost names

By

Serve your dev server at https://myapp.localhost with Caddy’s local certificate authority, by hand with a Caddyfile or per project with Local Hoster.

~~~

Your site runs on HTTPS in production, but in development you open http://localhost:4321. Most of the time that’s fine. Then you work on secure cookies, an OAuth callback URL or another feature that expects HTTPS, and the difference shows up.

There’s a second annoyance: the port. Was this project on 3000, 4321 or 5173? If the usual port is busy, Vite picks the next one, and the URL changes from one day to the next.

Caddy fixes both. It can serve https://myapp.localhost with a certificate from its own local certificate authority and forward every request to your dev server. We’ll set that up by hand first. Then we’ll let Local Hoster do it for each project, with a stable port behind the name.

What you need

You need Caddy. On a Mac, install it with Homebrew:

brew install caddy

On Linux, use the package from your distribution or the Caddy install instructions. You also need a dev server that listens on a local port. The examples use an Astro project on port 4321.

For the Local Hoster part, you need Node.js 18 or newer on macOS or another Unix-like system, and a dev command that accepts a --port option, as Astro and Vite do. After purchasing Local Hoster, connect GitHub in Polar, accept the private repository invitation, and download the source from Releases. Open Terminal in that folder and run npm link to install the localhoster command. It has no runtime npm dependencies.

None of this is public hosting. The certificates and the addresses only work on your own computer, and you don’t need an account anywhere.

Why .localhost names need no setup

The .localhost domain is reserved for local development. Browsers like Chrome and Firefox send any name that ends in .localhost to your own computer, so myapp.localhost and api.localhost work without editing /etc/hosts or running a DNS server.

Caddy treats those names as internal. Instead of asking a public certificate authority, it issues the certificate itself from a local authority called “Caddy Local Authority”. The first time it does that, it tries to install that authority’s root certificate into your system’s trust store, which may ask for your password.

Do it by hand with a Caddyfile

Start your dev server as usual, so it answers on http://localhost:4321. Then create an empty folder with a file named Caddyfile in it:

myapp.localhost {
	reverse_proxy localhost:4321
}

The name on the first line is the address you’ll open. reverse_proxy forwards every request to your dev server. Indent with a tab: with spaces, Caddy prints a warning that the file isn’t formatted.

From that folder, run Caddy in the foreground:

caddy run

Caddy loads the Caddyfile from the current folder, creates the local authority, issues a certificate for myapp.localhost and starts listening on ports 80 and 443. Open https://myapp.localhost. Once the root certificate is trusted, you see your site with no certificate warning.

Plain HTTP redirects to HTTPS. Check it from another terminal:

curl -I http://myapp.localhost

You should get HTTP/1.1 308 Permanent Redirect with Location: https://myapp.localhost/. If you want to see the proxy in more detail, I go through it in the Caddy course lesson Proxy a local application.

This works, but it’s fixed to one port. When 4321 is busy and Vite moves your project to 4322, Caddy keeps sending requests to 4321. With five projects you also maintain five blocks. Press Ctrl+C to stop Caddy before the next step.

Start Caddy for Local Hoster

Local Hoster doesn’t use a Caddyfile. It adds and removes routes through Caddy’s admin API, so Caddy needs to be running with that API available. Run this from a folder that has no Caddyfile, so Caddy starts with a blank configuration:

caddy start

You should see Successfully started Caddy followed by its process ID. Caddy is now running in the background, and its admin API listens on localhost:2019. Check it:

curl localhost:2019/config/

A blank configuration prints null. Now make sure your system trusts Caddy’s local authority:

caddy trust

Caddy reuses the same root certificate, so this is a one-time step. caddy start keeps running until you run caddy stop or restart the computer. The Local Hoster README covers the Caddy setup in more detail.

Give your project a name

Add two things to your project’s package.json: a localhost field with the name you want, and localhoster in front of your dev command.

{
  "localhost": "myapp",
  "scripts": {
    "dev": "localhoster astro dev"
  }
}

For a Vite project, the script is localhoster vite. I run flaviocopes.com this way: its package.json has "localhost": "flaviocopes", so the site is always at https://flaviocopes.localhost.

Start the project the usual way:

npm run dev

Local Hoster picks a port for the project, adds an HTTP and an HTTPS route for myapp.localhost to Caddy and starts your dev command with that port. Your normal dev server output stays in the same terminal. Open https://myapp.localhost.

Local Hoster maps each name to the same port across restarts, so you can forget the number and type the name.

How Local Hoster serves a project at a .localhost addressRunning npm run dev starts Local Hoster, which reads the project name, picks a stable port, adds HTTP and HTTPS routes to Caddy and starts the dev server on that port. The browser reaches the dev server through Caddy. When the dev server stops, Local Hoster removes the routes.NoYesnpm run devRead the localhostnamePick the stable portAdd HTTP and HTTPSroutes to CaddyStart the dev serveron that portBrowser openshttps://myapp.localhostDev server stopped?Remove the routesand TLS config

Stop the server and run more projects

Press Ctrl+C to stop the dev server. Local Hoster removes the routes and TLS configuration it added, so nothing piles up in Caddy between sessions. Caddy itself keeps running, ready for the next project.

Give every project its own name and they can run side by side. A frontend at https://myapp.localhost and an API at https://api.localhost each get their own port, and you never type either number.

If something looks wrong

If the browser warns about the certificate, your system doesn’t trust Caddy’s local authority yet. Run caddy trust while Caddy is running. On Linux you may need sudo. Restart the browser if it keeps the old warning.

If Local Hoster can’t reach Caddy, check that curl localhost:2019/config/ answers. The caddy reverse-proxy shortcut command disables the admin API, so start Caddy with caddy start instead.

If the page shows a different project or Caddy answers with a 502, look at the dev server’s own output. If it says the port is in use and it’s trying another one, the dev server moved away from the port Local Hoster chose. Find what’s holding that port with Fix “EADDRINUSE: address already in use”, then start the project again.

If the address works in the browser but not in another program, that program may ask your operating system’s resolver, which doesn’t always send .localhost names to your computer. Chrome, Firefox and recent versions of curl handle them on their own.

If a phone or a Docker container gets a certificate error, that’s expected. Trust lives on each device, and only your computer trusts your Caddy authority, as I explain in Use local HTTPS.

Get Local Hoster

Give your development projects memorable .localhost URLs with local HTTPS through Caddy. Includes the localhoster CLI and full MIT source.

See Local Hoster → · $20 including tax, with full source code.

More tutorials for my apps →

Want me to talk about your product? You can sponsor this site.