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
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 onhttp://localhost:3000and your backend is onhttp://localhost:3001, they cannot share cookies. In production,app.example.organdapi.example.orgcan share cookies without issue. - The Secure flag: While modern browsers are smart enough to accept cookies with the
Secureflag onlocalhost(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
Domainflag: This is the issue that causes the nastiest surprises. Some browsers completely ignore theDomainflag on cookies set fromlocalhost. 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 theDomainflag in production could mean a cookie set forexample.orgisn’t available onapp.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.