Motivation
When I’m doing webdev, I want to see how things work on multiple devices. Notice that I didn’t say “screen sizes”; I said “devices.” That’s because, even if a webpage appears correctly in my chromium devtools screen size emulation for a Pixel 5, it won’t necessarily display properly in the chromium mobile app on my Pixel 5, let alone in e.g. the firefox mobile app. Ultimately, the only way to know for certain that your webapp will work on the device + application
combination you care about is by actually checking it1.
Sometimes, I also don’t want to devote monitor real estate to my live preview, when I have a perfectly good laptop otherwise on standby. Watching the state of my app in realtime on multiple devices not only adds testing functionality but convenience.
Another Example Use Case: Developing on my laptop.
Sometimes, after sitting for a very long time, I’d like to do some standing desk work. The problem is that I do not own a “standing-and-sitting” desk; I own a sitting desk, and I own a standing desk. My sitting desk lives in my office, next to my desktop PC, and my standing desk lives in my garage, because it is actually just a crank-adjustable workbench.
When I want to move between the two, I typically rely on ssh
for continuity, since most everything I’m doing is in the terminal anyways. inb4 “you can just use git.” I don’t want to use git
to synchronize because I might not be at “a good stopping point” when I decide to change habitats, and “just a few more minutes” generally means I’m not going to make it to the standing desk that day. I want to be able to stop mid-sentence (“mid-line”?) and reattach my whole tmux session from the laptop in my garage. This is awesome, and git does not accomplish the same thing.
The problem, however, is that my browser does not run over ssh (and seeing how the styling changes in lynx is not helpful), so I need to be able to watch my development server in real time over the local network.
Hugo
Doing this with a hugo development server is incredibly easy, because the development server is incredibly simple (which results in some dev experience nuissances, but so be it–I love hugo). Now, most development servers have an option to serve on 0.0.0.0 instead of just 127.0.0.1 (aka localhost), but managing configurations for everything is a bit of a pain, and it simply doesn’t always work for complicated use cases (more on that later). So, instead of doing that and going to “192.168.0.123:1313” (ew!) on my other machines/devices, I use a local DNS CNAME record and an Nginx proxy pass.
I already have existing A records for all of my local devices, giving them unique local domain names, so all I have to do for a new subdomain is create a CNAME record, like “hugo.mac.lan”, pointing to “mac.lan”. That part is easy and straightforward. I also like to add a local record for amore common domain suffix that browsers will automatically interpret, like “.com” or “.net” etc. This prevents visiting the actual site at that address, but generally losing access to one website on the internet still leaves you with a lot of options for internet browsing.
Configuring the Nginx proxy, assuming you already have Nginx installed, is also pretty easy!
server {
listen 80;
server_name myhugo.com hugo.pc.lan;
# allow only local network IPs
allow 192.168.0.0/24;
deny all;
location / {
proxy_pass http://localhost:1313;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
Boom. Just like that,2 I can go to “myhugo.com” in any browser on any device connected to my local network, and I can see the state of my hugo development server (even though I still have to refresh).
Another advantage of the local DNS option is that I can also use the actual domain I plan to deploy to without needing to change the base_url
value in my hugo config.toml
, which is otherwise pretty annoying for version control.
Vite
So far, this all probably sounds a bit excessive, since I could also just use the built-in --host
/-H
/--bind
flag that seems to exist for every development server. Not so fast. What if I want to use a standalone React SPA as a frontend and communicate from the client to a totally separate backend webserver?
Now, as the use case becomes increasingly complex, the complexity of the catchall solution implementation begins to melt away. I don’t want to have to rely on specific implementions of the “host” flag in every development webserver I use. It’s great and all that Vite has a proxy built in:
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': {
target: 'http://localhost:5000',
changeOrigin: true,
}
}
}
})
But I might not care to figure that out for everything else.
In fact, trying to configure exactly this, using the built-in tools, was hellishly complicated and annoying. Much easier was simply performing the proxying myself (in Nginx). Now, instead of Vite rewriting and forwarding my requests from its own port and path to those of my Flask webserver (in this specific case), Nginx does so before Vite even sees “/api”:
...
location /api {
proxy_pass http://localhost:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
...
Does the Vite development server forward my “/api” requests itself or just rename the requests and let my client try to fetch from “localhost:5000” on another machine? I don’t care! Because now I don’t have to worry about specific proxy implementations.
Making Nginx my proxy for everything also helps me ascend from CORS hell during development, since reverse proxy decides to which server every request should be routed, eliminating a layer of complexity by removing cross-origin requesting altogether. Win-win.
Complications
One complication that I didn’t mention is that hot-reloading/HMR development servers are going to be using websockets, which requires a few extra lines of code in your Nginx site config:
...
location / {
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; # Manage WebSocket connections
proxy_set_header Connection "upgrade";
...
To be honest, I was surprised it was that easy too.
Another consideration is that, even when on your development machine, you cannot use localhost:5173
anymore, because you’ve offloaded your backend API call routing to Nginx. Just go to the assigned local DNS domain, as you would on your other devices, and things work perfectly.
-
I literally have two phones for this purpose: it is not uncommon that one of Android or Apple devices (using Safari…) doesn’t work while the other does. You don’t want to find this out after deploying to production. ↩︎
-
I already have the necessary router configuration and device firewall rules setup for this to work. ↩︎