Skip to main content

development

Local Development with Custom Domains

Use custom domains locally without turning your setup into a guessing game. This guide covers hosts files, local DNS, HTTPS, and when to use a real public subdomain.

Published
Updated
Reviewed

Source review: Corrected against mkcert's documented output-path options, Caddy's manual TLS syntax, RFC 2606 reserved test names, and current web.dev cookie guidance.

localhost is fine until the thing you are testing depends on a real hostname.

That change usually happens earlier than teams expect. The moment cookies, OAuth callbacks, webhook URLs, or host-based routing enter the picture, a custom local domain stops feeling fancy and starts feeling necessary.

Before you start

Write down these four things first:

  • which hostnames you actually need locally
  • whether only one machine needs them or several devices do
  • whether HTTPS is required for the flow you are testing
  • whether the outside world needs to reach the environment

That last question is the one that separates “better local naming” from “time to use a real public hostname.”

Who this is for

Use this workflow if you need to test any of the following locally:

  • multiple services such as app.local.test and api.local.test
  • cookies that behave differently on a real hostname
  • OAuth or callback flows that should not point at localhost
  • host-based routing that will later run behind a public subdomain

Some problems need a better local name, while others need a public endpoint. If an external service must call the environment, compare the trade-offs in Stable Subdomain vs Tunnel URL before publishing DNS.

Start with the smallest realistic setup

If only one developer on one machine needs the hostname, the simplest working setup is usually:

  1. a hosts file entry
  2. a clear naming pattern
  3. local HTTPS

That gets you most of the realism without adding new infrastructure too early.

1. Use a hosts file for single-machine testing

The minimal version looks like this:

127.0.0.1    app.local.test
127.0.0.1    api.local.test

This is often enough for:

  • cookie testing
  • local API routing
  • frontend and backend running on separate local names
  • checking how the app behaves on a domain-like host instead of localhost

The main limitation is that it only works cleanly on the machine you edited.

2. Add HTTPS early with mkcert

For realistic browser behavior, add local certificates before the workflow becomes messy.

Example:

mkdir -p ./certs
mkcert -install
mkcert -cert-file ./certs/local-dev.pem \
  -key-file ./certs/local-dev-key.pem \
  app.local.test api.local.test

Typical output looks like:

Created a new local CA
The local CA is now installed in the system trust store
Created a certificate valid for:
 - "app.local.test"
 - "api.local.test"
The certificate is at "./certs/local-dev.pem"
The key is at "./certs/local-dev-key.pem"

This command creates one certificate with both names in its Subject Alternative Name list. The explicit -cert-file and -key-file options keep the Caddy paths predictable; without them, mkcert derives a filename such as app.local.test+1.pem from the first name and the number of additional names.

The .test top-level name is reserved for testing by RFC 2606, so app.local.test cannot collide with a delegated public domain. Keep mkcert’s rootCA-key.pem private: anyone who obtains that key can create certificates trusted by machines where the local CA is installed.

Field capture

For a healthy local-domain setup, the useful proof is not just the hosts entry. It is the certificate generation and the first HTTPS response working together.

Illustrative example using reserved example domains and IPs.

Illustrative terminal example showing hosts entries, mkcert output, and a local HTTPS check

Once those files exist, wire them into your local dev server or reverse proxy. That step matters because secure cookies, browser APIs, and callback flows behave much closer to production over HTTPS.

Example local reverse proxy setup

For a small local stack, a Caddyfile can be enough:

app.local.test {
  tls ./certs/local-dev.pem ./certs/local-dev-key.pem
  reverse_proxy 127.0.0.1:3000
}

api.local.test {
  tls ./certs/local-dev.pem ./certs/local-dev-key.pem
  reverse_proxy 127.0.0.1:4000
}

Both site blocks use the same certificate because that certificate covers both hostnames. Caddy’s tls <cert_file> <key_file> form expects a matching PEM certificate and private key; it does not infer filenames created by mkcert.

3. Move to local DNS when more than one device matters

Hosts files stop being pleasant when:

  • multiple developers need the same names
  • phones or tablets need to resolve the local environment
  • a shared lab or office network is part of the test path

At that point, local DNS is usually cleaner than copying manual entries to every device.

4. Know when local is no longer enough

A custom local domain is still local. It does not magically become public just because it looks polished.

If a third-party system needs to reach your app, you need either:

  • a tunnel to the local environment, or
  • a real public environment behind a stable hostname

That is usually the moment when a dev or preview subdomain becomes more useful than one more layer of local workarounds.

A practical local setup

For a single developer, a solid baseline looks like this:

127.0.0.1    app.local.test
127.0.0.1    api.local.test
mkdir -p ./certs
mkcert -install
mkcert -cert-file ./certs/local-dev.pem \
  -key-file ./certs/local-dev-key.pem \
  app.local.test api.local.test

Then point your dev server or reverse proxy at those certificate files and verify the app actually serves the expected hostnames.

After that, a quick response check should look ordinary instead of mysterious:

curl -I https://app.local.test
HTTP/2 200
content-type: text/html; charset=utf-8
set-cookie: session=...

Common failure cases

Everything worked on localhost, then cookies broke

That usually means the app was never tested under the domain conditions it will actually use later. A real local hostname catches this much earlier.

The certificates exist, but the server is not using them

mkcert only creates the files. Your local server, proxy, or framework still has to be configured to serve them.

Teams mix too many naming patterns

If one service uses app.local.test, another uses local.app, and a third uses dev.localhost, confusion grows faster than the environment.

A local domain is mistaken for a public integration endpoint

If Stripe, GitHub, or another external service needs to call you, a local-only host is not enough. That is the point where a real public hostname or tunnel belongs in the conversation.

For one developer on one machine:

  1. use a hosts file
  2. use a consistent .local.test naming pattern
  3. add HTTPS with mkcert

For shared testing or public integrations:

  1. move to local DNS or a shared environment
  2. use a stable public subdomain when outside systems need access

Practical takeaway

Custom local domains are valuable because they make development behave more like the public web. The right setup is the smallest one that matches the behavior you actually need to test, no more and no less. When the environment becomes public, continue with the separate DNS, origin-binding, and certificate checks in the subdomain setup guide.

Sources and further reading

Vendor behavior can change. Use these primary sources to confirm the current product-specific details.

Continue learning

  1. 01
    Subdomain Setup: DNS, Routing, and HTTPS

    Plan a subdomain as a complete public endpoint: choose the record, prepare host routing, account for DNS caches, and verify TLS and application behavior.

    Read
  2. 02
    A Records and CNAME Records: Choosing a DNS Target

    Compare direct IPv4 records with hostname aliases, including CNAME coexistence and zone-apex constraints, provider-specific alias features, and verification steps.

    Read
  3. 03
    DNS Propagation: TTLs, Caches, and Verification

    Understand why DNS answers differ after a change, how TTL and negative caching affect what resolvers return, and how to separate authoritative DNS from recursive and application issues.

    Read