#engineering #webdev #productivity
Rob Bogie

Stop using localhost: bugs it creates and how to prevent them

How switching to realistic domains with SSL enabled can eliminate an entire class of production and integration bugs

A laptop with a padlock on top of it (Photo by FlyD on Unsplash)

You started a new project and worked hard to get your MVP ready for release. You’re checking everything one last time before you put it online, and everything works precisely as you wanted it to. You upload your project to your hosting provider, attach a domain to it, and request an SSL certificate. All done. You look over your first release with pride, until… you can’t seem to log in.

It turns out that your local development environment is not the same as your production one. Quite often, this difference comes from a single overlooked source: connecting to your server through localhost. Almost every web server “hello world” tutorial starts with connecting to http://localhost:4321 or something similar. We see this so often that it doesn’t even register as something to question.

However, our reliance on localhost during development can cause a host of headaches; some very visible, some hidden. It creates a dangerous gap between how your app behaves on your machine and how it behaves in the real world.

In this article, we’ll explore how localhost development can lead to problems like:

  • Broken logins and inaccurate cookie handling
  • Unrealistic and insecure CORS configurations
  • Brittle third-party integrations
  • The inability to use modern, secure browser features.

Most importantly, we’ll look at a different approach that can help you save frustration and prevent bugs before they ever reach production.


The critical flaws of developing on localhost

Why your logins break: The truth about cookies on localhost

When working with cookies, you’ll run into major differences in how they work on localhost versus an actual domain. If you’ve ever had an authentication flow work perfectly locally, only for it to fail in production, there’s a big chance it’s because of how localhost handles cookies:

  • Port-specific domains: For localhost, the cookie domain is port-specific; for normal domains, it is not. This becomes a problem when running multiple services. If your frontend is on http://localhost:3000 and your backend is on http://localhost:3001, they cannot share cookies. In production, app.example.org and api.example.org can share cookies without issue.
  • The Secure flag: While modern browsers are smart enough to accept cookies with the Secure flag on localhost (even over HTTP), this wasn’t always the case and can still cause issues when debugging in older browsers.
  • No subdomains: You don’t have a concept of subdomains on localhost, which means you can’t test a huge part of how cookies are designed to work across a domain and its subdomains.
  • The Domain flag: This is the issue that causes the nastiest surprises. Some browsers completely ignore the Domain flag on cookies set from localhost. This forces you to make your cookie-setting code conditional, creating a code path you can’t test locally and increasing the risk of bugs. Forgetting to set the Domain flag in production could mean a cookie set for example.org isn’t available on app.example.org, breaking your application.

The localhost CORS trap that leaves you vulnerable

Say you have multiple clients that must communicate with the same backend. For example, a login client on http://localhost:3000, a dashboard on http://localhost:3001, and an authentication service on http://localhost:5000. You’ll quickly run into CORS errors on localhost.

To fix this, the backend needs to add a header telling the browser which origins are allowed to make requests. Since keeping track of random local ports is a chore, it’s tempting to use a wildcard (*) to allow any origin. This solves the problem during development, but if that wildcard configuration is accidentally deployed to production, it creates a significant security vulnerability.

By using realistic domains locally, like https://login.example.test and https://dashboard.example.test, you can use the same CORS configuration you would in production, only needing to swap the TLD.

The chaos of microservice dependencies on localhost

Modern systems are often split across multiple microservices. When working on one part of such a system, you often need all the other services running, resulting in a mess of different ports on localhost. Your client code has to keep track of every port for every microservice it talks to.

This creates a major gap in environment parity (the principle that your development environment should mirror production as closely as possible). In production, services communicate over secure HTTPS on clean domains, likely behind a loadbalancer or service mesh. By hardcoding localhost:PORT connections, you create two completely different ways of resolving dependencies, doubling the configuration work and the chances for error.

When payment gateways and OAuth fail on localhost

These days, many third-party integrations run directly in the frontend. Whether you’re using an authentication platform like Auth0 via OAuth or a checkout flow from Stripe, these services often need to redirect the user back to your application.

Here’s the catch: many providers do not allow redirecting back to insecure http:// URLs for security reasons. When you try to configure your OAuth application or payment probider, you may find that http://localhost:3000 is simply not a valid redirect URI. This forces you to either find a workaround or skip testing these critical flows locally altogether.

Locked out: secure features that require HTTPS

The modern web platform offers powerful features that can only be used in secure environments. This includes APIs for Geolocation, Notifications, and Service Workers. While most browsers make a special exception for localhost to aid development, this support isn’t always perfect, and the behavior can differ from a true https domain. By developing with a local domain and a valid SSL certificate, you can test these features in an environment that matches production.


Using realistic domains with the .test TLD

So, how do we fix this? Instead of connecting to our services through localhost and a port, we will connect to local domains like app.example.test or myapp.test. This way, we can use the same configuration and dependencies as in production, without having to worry about port conflicts or CORS issues..

We use the .test TLD because it is a special TLD reserved by internet standards specifically for development and testing. There is a guarantee that domains ending with .test will never exist on the public internet, so we can use them freely without having to worry about conflicts.

Setting up local .test domains

The easiest way to make your computer recognize these domains is by editing your hosts file. This file acts as a local DNS resolver. You can find the file in the following location:

  • Linux/macOS: /etc/hosts
  • Windows: C:\Windows\System32\drivers\etc\hosts

You’ll need administrator/root permissions to edit it. Here is an example of what it looks like on macOS. To add our new domains, we simply point them to the loopback IP address (127.0.0.1 for IPv4 and ::1 for IPv6).

##
# Host Database
#
# localhost is used to configure the loopback interface
# when the system is booting.  Do not change this entry.
##
127.0.0.1       localhost
255.255.255.255 broadcasthost
::1             localhost

127.0.0.1       api.awesomeapp.test
::1             api.awesomeapp.test
127.0.0.1       admin.awesomeapp.test
::1             admin.awesomeapp.test
127.0.0.1       awesomeapp.test
::1             awesomeapp.test

Note: For more advanced use cases like wildcard domains, you may need a tool like Dnsmasq (Linux/macOS) or NextDNS (Windows).

Forwarding traffic to the correct ports

Now your domains point to your machine, but we still need to get rid of the ports. The goal is to access everything through the standard HTTPS port (443). To do this, you need a reverse proxy (or load balancer) to route traffic from a domain to the correct local port.

Great options for this include Traefik, NGINX, or Caddy. Below is an example configuration for Traefik that redirects our domains to three different services running on ports 3000, 3001, and 5000.

# traefik-config.yaml
tls:
  stores:
    default:
      defaultCertificate:
        certFile: /path/to/my/server.pem
        keyFile: /path/to/my/server-key.pem
http:
  routers:
    api:
      rule: Host(`api.awesomeapp.test`)
      priority: 1
      service: api
      entryPoints:
        - https
      tls: true
    admin:
      rule: Host(`admin.awesomeapp.test`)
      priority: 1
      service: admin
      entryPoints:
        - https
      tls: true
    website:
      rule: Host(`awesomeapp.test`)
      priority: 1
      service: website
      entryPoints:
        - https
      tls: true
  services:
    api:
      loadBalancer:
        servers:
          - url: http://localhost:5000
    admin:
      loadBalancer:
        servers:
          - url: http://localhost:3001
    website:
      loadBalancer:
        servers:
          - url: http://localhost:3000

To use this config, you can run Traefik with the following command:

traefik \
  --log.level=INFO \
  --accesslog=true \
  --providers.file.directory=<pathToConfig> \
  --providers.file.watch=true \
  --entryPoints.http.address=:80 \
  --entrypoints.http.http.redirections.entryPoint.to=https \
  --entrypoints.http.http.redirections.entryPoint.scheme=https \
  --entryPoints.https.address=:443 \
  --entryPoints.https.http.tls={}

Replace <pathToConfig> with the path to the folder in which your Traefik config can be found. Make sure the only YAML files in this folder are valid traefik configs.

Generating your SSL certificate

The final piece is generating a locally-trusted SSL certificate. While you can do this with command-line tools like OpenSSL, a much simpler way is to use a tool like mkcert. It automates creating and installing a local Certificate Authority (CA) in your system’s trust store, so your browser won’t show any security warnings.

You can generate a certificate for our domains as follows:

# Install a new local certificate authority (only needs to be done once)
$ mkcert -install
Created a new local CA 💥
The local CA is now installed in the system trust store! ⚡️
The local CA is now installed in the Firefox trust store (requires browser restart)! 🦊

# Generate a certificate for your domains
$ mkcert awesomeapp.test api.awesomeapp.test admin.awesomeapp.test

Created a new certificate valid for the following names 📜
 - "awesomeapp.test"
 - "api.awesomeapp.test"
 - "admin.awesomeapp.test"

The certificate is at "./awesomeapp.test+2.pem" and the key at "./awesomeapp.test+2-key.pem" ✅

You would then point your reverse proxy (Traefik) to these two generated files.


Conclusion: It’s time to ditch localhost

As you can see, setting up a production-like local environment involves a few moving parts: editing hosts files, managing a reverse proxy, and generating SSL certificates. It’s a significant one-time setup, but it’s an investment that pays in the long run by eliminating an entire class of bugs.

It might seem like a small thing, connecting to http://localhost:3000 every day. We’ve all done it for years, and it feels like the simplest way to get started. But as we’ve seen, this simple habit is the source of significant frustration that only shows up later.

Bugs with cookies, confusing CORS policies, and broken payment flows aren’t random problems. They are often symptoms of a local development environment that simply doesn’t match how your application will run in the real world. Every shortcut we take locally, like using wildcard CORS headers, is a bug waiting to happen in production.

By switching to realistic local domains, you are adopting a professional discipline called environment parity. It means you are testing your real security policies, your real cookie behavior, and your real service architecture every single time you run locally. The goal is to make your final deployment boring because you’ve already solved all the surprising integration problems on your own machine.

Abandoning localhost isn’t about making your setup more complex; it’s about making your work more predictable. It’s a small change in process that pays back big in saved time and fewer headaches. Your future self, who isn’t debugging a mysterious login issue late at night, will thank you for it.

Stop fixing these issues manually.

Lode automates everything you just read about. Join the beta to get a Free Lifetime Pro License.

Get Lode Free